Redesign admin UI as transit operations command center with sidebar navigation, design system, and dashboard
This commit is contained in:
+10
-3
@@ -1,13 +1,20 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transit Admin</title>
|
||||
<meta name="theme-color" content="#0e1526" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;550;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Transit Beheercentrum</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/env-config.js"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+88
-25
@@ -1,15 +1,35 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Login from './components/Login'
|
||||
import Overview from './components/Overview'
|
||||
import StopsManager from './components/StopsManager'
|
||||
import LinkStop from './components/LinkStop'
|
||||
import BusLinesManager from './components/BusLinesManager'
|
||||
import FleetManager from './components/FleetManager'
|
||||
import ScheduleMaker from './components/ScheduleMaker'
|
||||
import ErrorLogViewer from './components/ErrorLogViewer'
|
||||
import { IconAlert, IconBus, IconCalendar, IconGrid, IconLink, IconLogout, IconMapPin, IconRoute } from './components/icons'
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'overview', label: 'Overzicht', eyebrow: 'Operations', title: 'Dagelijks overzicht', desc: 'Vloot, netwerk en dienstrooster in één oogopslag.', icon: IconGrid },
|
||||
{ id: 'stops', label: 'Haltes', eyebrow: 'Netwerkbeheer', title: 'Haltes', desc: 'Maak herbruikbare haltes aan en bewerk ze.', icon: IconMapPin },
|
||||
{ id: 'lines', label: 'Buslijnen', eyebrow: 'Netwerkbeheer', title: 'Buslijnen', desc: 'Definieer lijnen tussen start- en eindhalte.', icon: IconRoute },
|
||||
{ id: 'routes', label: 'Lijnroutes', eyebrow: 'Netwerkbeheer', title: 'Stops per lijn', desc: 'Koppel haltes aan lijnen en bepaal de volgorde.', icon: IconLink },
|
||||
{ id: 'fleet', label: 'Vloot', eyebrow: 'Vlootbeheer', title: 'Vloot', desc: 'Beheer de voertuigen die deel uitmaken van de vloot.', icon: IconBus },
|
||||
{ id: 'schedules', label: 'Diensten', eyebrow: 'Planning', title: 'Dienstenplanning', desc: 'Plan wekelijkse schema’s en vaste diensten.', icon: IconCalendar },
|
||||
{ id: 'logs', label: 'Foutmeldingen', eyebrow: 'Systeem', title: 'Centrale foutmeldingen', desc: 'Bekijk fouten die door alle diensten zijn gemeld.', icon: IconAlert },
|
||||
]
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{ label: 'Algemeen', items: ['overview'] },
|
||||
{ label: 'Netwerkbeheer', items: ['stops', 'lines', 'routes', 'fleet'] },
|
||||
{ label: 'Planning', items: ['schedules'] },
|
||||
{ label: 'Systeem', items: ['logs'] },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const [section, setSection] = useState('overview')
|
||||
|
||||
useEffect(() => {
|
||||
if (token) localStorage.setItem('token', token)
|
||||
@@ -21,34 +41,77 @@ export default function App() {
|
||||
else localStorage.removeItem('user')
|
||||
}, [user])
|
||||
|
||||
if (!token) return <Login onLogin={(t,u) => { setToken(t); setUser(u); }} />
|
||||
if (!token) return <Login onLogin={(t, u) => { setToken(t); setUser(u); setSection('overview') }} />
|
||||
|
||||
const active = SECTIONS.find((s) => s.id === section) || SECTIONS[0]
|
||||
const initials = (user?.name || user?.userCode || 'U').toString().slice(0, 1).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">Transit operations</p>
|
||||
<h1>Admin control center</h1>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">🚌</div>
|
||||
<div className="brand-text">
|
||||
<div className="brand-name">Transit Ops</div>
|
||||
<div className="brand-sub">Beheercentrum</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="userbox">
|
||||
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
|
||||
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="hero">
|
||||
<div>
|
||||
<h2>Fleet, lijnen en stops</h2>
|
||||
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
|
||||
|
||||
<nav>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="nav-label">{group.label}</div>
|
||||
{group.items.map((id) => {
|
||||
const item = SECTIONS.find((s) => s.id === id)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`nav-item ${section === id ? 'active' : ''}`}
|
||||
onClick={() => setSection(id)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-chip">
|
||||
<div className="user-avatar">{initials}</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{user?.name || user?.userCode || 'Gebruiker'}</div>
|
||||
<div className="user-role">{user?.isAdmin ? 'Beheerder' : 'Medewerker'}</div>
|
||||
</div>
|
||||
<button className="logout-btn" title="Uitloggen" onClick={() => { setToken(null); setUser(null) }}>
|
||||
<IconLogout size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<div className="topbar-title">
|
||||
<p className="eyebrow">{active.eyebrow}</p>
|
||||
<h1>{active.title}</h1>
|
||||
<p>{active.desc}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{section === 'overview' && <Overview token={token} user={user} />}
|
||||
{section === 'stops' && <StopsManager token={token} user={user} />}
|
||||
{section === 'lines' && <BusLinesManager token={token} user={user} />}
|
||||
{section === 'routes' && <LinkStop token={token} user={user} />}
|
||||
{section === 'fleet' && <FleetManager token={token} user={user} />}
|
||||
{section === 'schedules' && <ScheduleMaker token={token} user={user} />}
|
||||
{section === 'logs' && <ErrorLogViewer token={token} />}
|
||||
</main>
|
||||
</div>
|
||||
<main>
|
||||
<StopsManager token={token} user={user} />
|
||||
<BusLinesManager token={token} user={user} />
|
||||
<FleetManager token={token} user={user} />
|
||||
<LinkStop token={token} user={user} />
|
||||
<ScheduleMaker token={token} user={user} />
|
||||
<ErrorLogViewer token={token} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
import Modal from './Modal'
|
||||
|
||||
export default function BusLinesManager({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
@@ -11,16 +12,19 @@ export default function BusLinesManager({ token, user }) {
|
||||
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [messageType, setMessageType] = useState('')
|
||||
const [isAdmin] = useState(Boolean(user?.isAdmin))
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||
const [editingLine, setEditingLine] = useState(null)
|
||||
|
||||
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||
|
||||
const loadBusLines = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/bus-lines')
|
||||
setBusLines(res.data.busLines || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +33,7 @@ export default function BusLinesManager({ token, user }) {
|
||||
const res = await client.get('/admin/stops')
|
||||
setStops(res.data.stops || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,43 +42,56 @@ export default function BusLinesManager({ token, user }) {
|
||||
loadStops()
|
||||
}, [])
|
||||
|
||||
const stopNameById = (id) => stops.find(s => String(s.id) === String(id))?.name
|
||||
const stopIdByName = (name) => (stops.find(s => s.name === name) || {}).id
|
||||
|
||||
const createBusLine = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
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', {
|
||||
lineNumber: Number(lineNumber),
|
||||
startStop: startStopName,
|
||||
endStop: endStopName,
|
||||
startStop: stopNameById(startStop) || startStop,
|
||||
endStop: stopNameById(endStop) || endStop,
|
||||
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
||||
description,
|
||||
})
|
||||
setLineNumber('')
|
||||
setStartStop('')
|
||||
setEndStop('')
|
||||
setEstimatedDurationMinutes('')
|
||||
setDescription('')
|
||||
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
|
||||
setLineNumber(''); setStartStop(''); setEndStop(''); setEstimatedDurationMinutes(''); setDescription('')
|
||||
notify('Buslijn aangemaakt: #' + (res.data.busLine?.id || 'ok'))
|
||||
await loadBusLines()
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveBusLine = async (e) => {
|
||||
e.preventDefault()
|
||||
notify(null)
|
||||
try {
|
||||
await client.patch(`/admin/bus-lines/${editingLine.id}`, {
|
||||
lineNumber: Number(editingLine.lineNumber),
|
||||
startStop: stopNameById(editingLine.startStop) || editingLine.startStop,
|
||||
endStop: stopNameById(editingLine.endStop) || editingLine.endStop,
|
||||
estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes),
|
||||
description: editingLine.description,
|
||||
})
|
||||
notify('Buslijn bijgewerkt')
|
||||
setEditingLine(null)
|
||||
await loadBusLines()
|
||||
} catch (err) {
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBusLine = async (lineId, lineNumber) => {
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
await client.delete(`/admin/bus-lines/${lineId}`)
|
||||
setMessage(`Buslijn ${lineNumber} verwijderd`)
|
||||
notify(`Buslijn ${lineNumber} verwijderd`)
|
||||
setDeleteConfirm(null)
|
||||
await loadBusLines()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
setMessage('Error: ' + errMsg)
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,42 +100,31 @@ export default function BusLinesManager({ token, user }) {
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Buslijnen</h2>
|
||||
<p>Maak en bewerk buslijnen.</p>
|
||||
<p>Definieer lijnen tussen start- en eindhalte.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadBusLines}>Refresh</button>
|
||||
<button className="secondary" onClick={loadBusLines}>Ververs</button>
|
||||
</div>
|
||||
|
||||
<div className="list-grid">
|
||||
{busLines.map(line => (
|
||||
<article className="mini-card" key={line.id}>
|
||||
<strong>#{line.id} Lijn {line.lineNumber}</strong>
|
||||
<div>{line.startStop} → {line.endStop}</div>
|
||||
<div>{line.estimatedDurationMinutes} min</div>
|
||||
{line.description ? <div className="muted">{line.description}</div> : null}
|
||||
<strong><span className="badge badge-primary">Lijn {line.lineNumber}</span></strong>
|
||||
<div className="meta-line">#{line.id}</div>
|
||||
<div className="meta-line">{line.startStop} → {line.endStop}</div>
|
||||
<div className="meta-line"><span className="badge badge-accent">{line.estimatedDurationMinutes} min</span></div>
|
||||
{line.description ? <div className="muted" style={{ marginTop: 4 }}>{line.description}</div> : null}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => setDeleteConfirm(line)}
|
||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||
>
|
||||
Delete
|
||||
</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>
|
||||
</>
|
||||
<div className="card-actions" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||
<button className="secondary" onClick={() => setEditingLine({
|
||||
id: line.id,
|
||||
lineNumber: line.lineNumber,
|
||||
startStop: stopIdByName(line.startStop) || line.startStop,
|
||||
endStop: stopIdByName(line.endStop) || line.endStop,
|
||||
estimatedDurationMinutes: line.estimatedDurationMinutes,
|
||||
description: line.description || '',
|
||||
})}>Bewerken</button>
|
||||
<button className="danger" onClick={() => setDeleteConfirm(line)}>Verwijderen</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
@@ -127,120 +133,66 @@ export default function BusLinesManager({ token, user }) {
|
||||
{isAdmin ? (
|
||||
<form onSubmit={createBusLine} className="stack-form">
|
||||
<div className="form-row">
|
||||
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
|
||||
<input placeholder="Lijnnummer" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
|
||||
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
|
||||
<option value="">Start stop</option>
|
||||
{stops.map(stop => (
|
||||
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
||||
))}
|
||||
<option value="">Start halte</option>
|
||||
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||
</select>
|
||||
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
|
||||
<option value="">End stop</option>
|
||||
{stops.map(stop => (
|
||||
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
||||
))}
|
||||
<option value="">Eind halte</option>
|
||||
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
||||
<input placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<button type="submit">Create bus line</button>
|
||||
<input placeholder="Reistijd in minuten" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
||||
<input placeholder="Omschrijving (optioneel)" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<button type="submit" className="no-grow">Buslijn aanmaken</button>
|
||||
</div>
|
||||
</form>
|
||||
) : <div>You must be admin to create bus lines.</div>}
|
||||
) : <div className="muted">Alleen beheerders kunnen buslijnen aanmaken.</div>}
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
{message && <div className={`message ${messageType}`}>{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>
|
||||
<Modal title="Buslijn bewerken" onClose={() => setEditingLine(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setEditingLine(null)}>Annuleren</button>
|
||||
<button onClick={saveBusLine}>Opslaan</button>
|
||||
</>
|
||||
}>
|
||||
<div className="form-row">
|
||||
<input value={editingLine.lineNumber} onChange={e => setEditingLine({ ...editingLine, lineNumber: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<select value={editingLine.startStop} onChange={e => setEditingLine({ ...editingLine, startStop: e.target.value })}>
|
||||
<option value="">Start halte</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="">Eind halte</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>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{deleteConfirm && (
|
||||
<div style={overlay}>
|
||||
<div style={modal}>
|
||||
<h3>Delete Bus Line?</h3>
|
||||
<p><strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop})</p>
|
||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will also delete all stops on this line. Cannot be undone.</p>
|
||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal title="Buslijn verwijderen?" onClose={() => setDeleteConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>Verwijderen</button>
|
||||
</>
|
||||
}>
|
||||
<p>Weet je zeker dat je <strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop}) wilt verwijderen?</p>
|
||||
<p className="faint">Hiermee worden ook alle gekoppelde stops op deze lijn verwijderd. Dit kan niet worden ongedaan gemaakt.</p>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { errorLogClient } from "../api";
|
||||
import { IconAlert } from "./icons";
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return "—";
|
||||
@@ -10,6 +11,14 @@ const formatDate = (value) => {
|
||||
}
|
||||
};
|
||||
|
||||
const serviceTone = (service = "") => {
|
||||
if (service.includes("auth")) return "badge-primary";
|
||||
if (service.includes("employee")) return "badge-success";
|
||||
if (service.includes("transit")) return "badge-accent";
|
||||
if (service.includes("error")) return "badge-warning";
|
||||
return "badge-muted";
|
||||
};
|
||||
|
||||
export default function ErrorLogViewer({ token }) {
|
||||
const client = useMemo(() => errorLogClient(token), [token]);
|
||||
const [logs, setLogs] = useState([]);
|
||||
@@ -28,9 +37,7 @@ export default function ErrorLogViewer({ token }) {
|
||||
const res = await client.get("/error_log", { params });
|
||||
setLogs(res.data.errors || []);
|
||||
} catch (err) {
|
||||
setMessage(
|
||||
err.response?.data?.error || err.message || "Kon fouten niet laden",
|
||||
);
|
||||
setMessage(err.response?.data?.error || err.message || "Kon fouten niet laden");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -48,41 +55,55 @@ export default function ErrorLogViewer({ token }) {
|
||||
<p>Laatste foutmeldingen uit de centrale logging service.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadLogs} disabled={loading}>
|
||||
{loading ? "Laden..." : "Ververs"}
|
||||
{loading ? "Laden…" : "Ververs"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<input
|
||||
placeholder="Service filter"
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Part of service filter"
|
||||
value={partofservice}
|
||||
onChange={(e) => setPartofservice(e.target.value)}
|
||||
/>
|
||||
<input placeholder="Filter op service" value={service} onChange={(e) => setService(e.target.value)} />
|
||||
<input placeholder="Filter op locatie" value={partofservice} onChange={(e) => setPartofservice(e.target.value)} />
|
||||
{(service || partofservice) && (
|
||||
<button type="button" className="ghost no-grow" onClick={() => { setService(""); setPartofservice("") }}>
|
||||
Wissen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
{message && <div className="message error">{message}</div>}
|
||||
|
||||
<div className="list-grid">
|
||||
{logs.map((log) => (
|
||||
<article className="mini-card" key={log.id}>
|
||||
<strong>{log.service}</strong>
|
||||
<div>{formatDate(log.date)}</div>
|
||||
<div className="muted" style={{ marginTop: 6 }}>
|
||||
{log.partofservice || "—"}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>{log.error}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!logs.length && !message && (
|
||||
<div className="muted">Nog geen foutmeldingen gevonden.</div>
|
||||
{logs.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Service</th>
|
||||
<th>Locatie</th>
|
||||
<th>Fout</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(log.date)}</td>
|
||||
<td>
|
||||
<span className={`badge ${serviceTone(log.service)}`}>{log.service}</span>
|
||||
</td>
|
||||
<td className="truncate-cell">{log.partofservice || "—"}</td>
|
||||
<td>
|
||||
<span className="error-cell" title={log.error}>{log.error}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '36px 0', color: 'var(--text-faint)' }}>
|
||||
<IconAlert size={28} />
|
||||
<p style={{ margin: '10px 0 0' }}>Nog geen foutmeldingen gevonden.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1082,19 +1082,24 @@ const overlay = {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
backgroundColor: 'rgba(15,23,42,0.55)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: 20,
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
padding: '1.5rem 1.75rem 1.75rem',
|
||||
borderRadius: '16px',
|
||||
maxWidth: '440px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 44px rgba(15,23,42,0.16)',
|
||||
maxHeight: '88vh',
|
||||
overflowY: 'auto',
|
||||
}
|
||||
|
||||
export default FixedScheduleEditor
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
import Modal from './Modal'
|
||||
import { IconBus } from './icons'
|
||||
|
||||
export default function FleetManager({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
@@ -8,67 +10,74 @@ export default function FleetManager({ token, user }) {
|
||||
const [seatCapacity, setSeatCapacity] = useState('')
|
||||
const [licensePlate, setLicensePlate] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [messageType, setMessageType] = useState('')
|
||||
const [canCreateBus, setCanCreateBus] = useState(true)
|
||||
const [isAdmin] = useState(Boolean(user?.isAdmin))
|
||||
const [editingBus, setEditingBus] = useState(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||
|
||||
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||
|
||||
const loadBuses = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/fleet/buses')
|
||||
setBuses(res.data.buses || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBuses()
|
||||
}, [])
|
||||
|
||||
const createBus = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
const res = await client.post('/admin/fleet/buses', {
|
||||
name,
|
||||
seatCapacity: Number(seatCapacity),
|
||||
licensePlate,
|
||||
name, seatCapacity: Number(seatCapacity), licensePlate,
|
||||
})
|
||||
setName('')
|
||||
setSeatCapacity('')
|
||||
setLicensePlate('')
|
||||
setMessage('Bus created: #' + (res.data.bus?.id || 'ok'))
|
||||
setName(''); setSeatCapacity(''); setLicensePlate('')
|
||||
notify('Bus aangemaakt: #' + (res.data.bus?.id || 'ok'))
|
||||
await loadBuses()
|
||||
} catch (err) {
|
||||
const details = err.response?.data?.details || err.response?.data?.error || err.message
|
||||
if (String(details).includes('permission denied') || err.response?.status === 403) {
|
||||
setCanCreateBus(false)
|
||||
setMessage('Fleet create is read-only on this database user.')
|
||||
notify('Vloot is read-only voor deze database-gebruiker.', 'error')
|
||||
return
|
||||
}
|
||||
setMessage('Error: ' + details)
|
||||
notify('Error: ' + details, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveBus = async (e) => {
|
||||
e.preventDefault()
|
||||
notify(null)
|
||||
try {
|
||||
await client.patch(`/admin/fleet/buses/${editingBus.id}`, {
|
||||
name: editingBus.name,
|
||||
seatCapacity: Number(editingBus.seatCapacity),
|
||||
licensePlate: editingBus.licensePlate,
|
||||
})
|
||||
notify('Bus bijgewerkt')
|
||||
setEditingBus(null)
|
||||
await loadBuses()
|
||||
} catch (err) {
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBus = async () => {
|
||||
notify(null)
|
||||
try {
|
||||
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
|
||||
notify('Bus verwijderd')
|
||||
setDeleteConfirm(null)
|
||||
await loadBuses()
|
||||
} catch (err) {
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,23 +85,24 @@ export default function FleetManager({ token, user }) {
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Fleet</h2>
|
||||
<p>Beheer voertuigen voor de vloot.</p>
|
||||
<h2>Vloot</h2>
|
||||
<p>Beheer voertuigen die deel uitmaken van de vloot.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadBuses}>Refresh</button>
|
||||
<button className="secondary" onClick={loadBuses}>Ververs</button>
|
||||
</div>
|
||||
|
||||
<div className="list-grid">
|
||||
{buses.map(bus => (
|
||||
<article className="mini-card" key={bus.id}>
|
||||
<strong>#{bus.id} {bus.name}</strong>
|
||||
<div>Seats: {bus.seatCapacity}</div>
|
||||
<div>Plate: {bus.licensePlate}</div>
|
||||
<strong><IconBus size={16} style={{ verticalAlign: '-2px', color: 'var(--primary)' }} /> {bus.name}</strong>
|
||||
<div className="meta-line">#{bus.id}</div>
|
||||
<div className="meta-line"><span className="badge badge-success">{bus.seatCapacity} zitplaatsen</span></div>
|
||||
<div className="meta-line" style={{ fontFamily: 'var(--font-mono)' }}>{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>
|
||||
</>
|
||||
<div className="card-actions">
|
||||
<button className="secondary" onClick={() => setEditingBus(bus)}>Bewerken</button>
|
||||
<button className="danger" onClick={() => setDeleteConfirm(bus)}>Verwijderen</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
@@ -101,73 +111,47 @@ export default function FleetManager({ token, user }) {
|
||||
{isAdmin && canCreateBus ? (
|
||||
<form onSubmit={createBus} className="stack-form">
|
||||
<div className="form-row">
|
||||
<input placeholder="Bus name" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="Seat capacity" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
|
||||
<input placeholder="License plate" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
|
||||
<button type="submit">Create bus</button>
|
||||
<input placeholder="Busnaam" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="Zitplaatsen" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
|
||||
<input placeholder="Kenteken" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
|
||||
<button type="submit" className="no-grow">Bus toevoegen</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="muted">Fleet create is read-only on this database user.</div>
|
||||
<div className="muted">Vloot is read-only voor deze database-gebruiker.</div>
|
||||
)}
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
{message && <div className={`message ${messageType}`}>{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>
|
||||
<Modal title="Bus bewerken" onClose={() => setEditingBus(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setEditingBus(null)}>Annuleren</button>
|
||||
<button onClick={saveBus}>Opslaan</button>
|
||||
</>
|
||||
}>
|
||||
<div className="form-row">
|
||||
<input value={editingBus.name} onChange={e => setEditingBus({ ...editingBus, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<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>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<Modal title="Bus verwijderen?" onClose={() => setDeleteConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||
<button className="danger" onClick={deleteBus}>Verwijderen</button>
|
||||
</>
|
||||
}>
|
||||
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> ({deleteConfirm.licensePlate}) wilt verwijderen?</p>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
import Modal from './Modal'
|
||||
|
||||
export default function LinkStop({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
@@ -9,31 +10,33 @@ export default function LinkStop({ token, user }) {
|
||||
const [stopId, setStopId] = useState('')
|
||||
const [eta, setEta] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [messageType, setMessageType] = useState('')
|
||||
const [stops, setStops] = useState([])
|
||||
const [draggedItem, setDraggedItem] = useState(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||
|
||||
const isAdmin = Boolean(user?.isAdmin)
|
||||
|
||||
const nextOrder = useMemo(() => stops.length + 1, [stops])
|
||||
|
||||
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||
|
||||
const loadBusLines = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/bus-lines')
|
||||
setBusLines(res.data.busLines || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const loadStopsForLine = async (lineId = busLineId) => {
|
||||
if (!lineId) return
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
const res = await client.get(`/bus-lines/${lineId}/stops`)
|
||||
setStops(res.data.stops || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,62 +45,48 @@ export default function LinkStop({ token, user }) {
|
||||
const res = await client.get('/admin/stops')
|
||||
setAvailableStops(res.data.stops || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshBoard = async () => {
|
||||
await Promise.all([
|
||||
loadBusLines(),
|
||||
loadAvailableStops(),
|
||||
loadStopsForLine(),
|
||||
])
|
||||
await Promise.all([loadBusLines(), loadAvailableStops(), loadStopsForLine()])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBusLines()
|
||||
loadAvailableStops()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStopsForLine()
|
||||
}, [busLineId])
|
||||
useEffect(() => { loadBusLines(); loadAvailableStops() }, [])
|
||||
useEffect(() => { loadStopsForLine() }, [busLineId])
|
||||
|
||||
const link = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
||||
stopId: parseInt(stopId, 10),
|
||||
stopOrder: nextOrder,
|
||||
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
|
||||
})
|
||||
setMessage('Linked: ' + res.data.routeStop.id)
|
||||
notify('Gekoppeld: ' + res.data.routeStop.id)
|
||||
setStopId('')
|
||||
await loadStopsForLine()
|
||||
} catch (err) {
|
||||
if (err.response?.status === 403) setMessage('Forbidden: not admin')
|
||||
else setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
if (err.response?.status === 403) notify('Forbidden: not admin', 'error')
|
||||
else notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const appendDraggedStop = async (stopToLink) => {
|
||||
if (!busLineId) {
|
||||
setMessage('Select a bus line first')
|
||||
return
|
||||
}
|
||||
|
||||
setMessage(null)
|
||||
if (!busLineId) { notify('Kies eerst een buslijn', 'error'); return }
|
||||
notify(null)
|
||||
try {
|
||||
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
||||
stopId: Number(stopToLink.id),
|
||||
stopOrder: nextOrder,
|
||||
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
|
||||
})
|
||||
setMessage(`Linked: ${stopToLink.name} as stop #${res.data.routeStop.stopOrder}`)
|
||||
notify(`Gekoppeld: ${stopToLink.name} als stop #${res.data.routeStop.stopOrder}`)
|
||||
await loadStopsForLine()
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +108,12 @@ export default function LinkStop({ token, user }) {
|
||||
nextStops.splice(toIndex, 0, moved)
|
||||
const normalized = nextStops.map((stop, index) => ({ ...stop, order: index + 1 }))
|
||||
setStops(normalized)
|
||||
setMessage('Saving new route order...')
|
||||
notify('Nieuwe volgorde opslaan…')
|
||||
try {
|
||||
await persistOrder(normalized)
|
||||
setMessage('Route order updated')
|
||||
notify('Routenvolgorde bijgewerkt')
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
await loadStopsForLine()
|
||||
}
|
||||
}
|
||||
@@ -139,23 +128,22 @@ export default function LinkStop({ token, user }) {
|
||||
setStops(normalized)
|
||||
try {
|
||||
await persistOrder(normalized)
|
||||
setMessage('Route order updated')
|
||||
notify('Routenvolgorde bijgewerkt')
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
await loadStopsForLine()
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRouteStop = async (routeStopId, stopName) => {
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
await client.delete(`/admin/bus-lines/${busLineId}/stops/${routeStopId}`)
|
||||
setMessage(`Halte "${stopName}" verwijderd uit de lijn`)
|
||||
notify(`Halte "${stopName}" verwijderd uit de lijn`)
|
||||
setDeleteConfirm(null)
|
||||
await loadStopsForLine()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
setMessage('Error: ' + errMsg)
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,28 +152,27 @@ export default function LinkStop({ token, user }) {
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Stops per lijn</h2>
|
||||
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
|
||||
<p>Koppel haltes aan een lijn en bepaal de rijvolgorde.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={refreshBoard}>Refresh</button>
|
||||
<button className="secondary" onClick={refreshBoard}>Ververs</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<select
|
||||
value={busLineId}
|
||||
onChange={e => setBusLineId(e.target.value)}
|
||||
>
|
||||
<option value="">Choose line</option>
|
||||
<select value={busLineId} onChange={e => setBusLineId(e.target.value)}>
|
||||
<option value="">Kies lijn</option>
|
||||
{busLines.map(line => (
|
||||
<option key={line.id} value={line.id}>
|
||||
#{line.id} lijn {line.lineNumber} - {line.startStop} → {line.endStop}
|
||||
#{line.id} Lijn {line.lineNumber} — {line.startStop} → {line.endStop}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => loadStopsForLine()} disabled={!busLineId}>Load Stops</button>
|
||||
<button type="button" className="secondary no-grow" onClick={() => loadStopsForLine()} disabled={!busLineId}>
|
||||
Laad stops
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="muted" style={{ marginBottom: 10 }}>
|
||||
Drag a stop onto the route board to append it to the line. Use the arrows to nudge the order.
|
||||
Sleep een halte op het routepane om hem toe te voegen. Gebruik de pijltjes om de volgorde aan te passen.
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -199,6 +186,11 @@ export default function LinkStop({ token, user }) {
|
||||
setDraggedItem(null)
|
||||
}}
|
||||
>
|
||||
{stops.length === 0 && (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
Nog geen haltes gekoppeld — sleep een halte hierheen.
|
||||
</p>
|
||||
)}
|
||||
{stops.map((s, index) => (
|
||||
<article
|
||||
className={`route-stop-item ${draggedItem?.type === 'route' && Number(draggedItem.id) === Number(s.id) ? 'dragging' : ''}`}
|
||||
@@ -208,9 +200,7 @@ export default function LinkStop({ token, user }) {
|
||||
onDragEnd={() => setDraggedItem(null)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (draggedItem?.type === 'route') {
|
||||
reorderStops(draggedItem.id, s.id)
|
||||
}
|
||||
if (draggedItem?.type === 'route') reorderStops(draggedItem.id, s.id)
|
||||
if (draggedItem?.type === 'available') {
|
||||
const stop = availableStops.find(item => Number(item.id) === Number(draggedItem.id))
|
||||
if (stop) appendDraggedStop(stop)
|
||||
@@ -219,25 +209,26 @@ export default function LinkStop({ token, user }) {
|
||||
}}
|
||||
>
|
||||
<div className="route-stop-handle" aria-hidden="true">⋮⋮</div>
|
||||
<div className="route-order-badge">{s.order}</div>
|
||||
<div className="route-stop-content">
|
||||
<strong>{s.order}. {s.name}</strong>
|
||||
<strong>{s.name}</strong>
|
||||
<div className="muted">{s.latitude}, {s.longitude}</div>
|
||||
{s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? (
|
||||
<div className="muted">ETA {s.estimatedArrivalMinutes} min</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="route-stop-actions">
|
||||
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
|
||||
<button type="button" className="secondary" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
|
||||
<button type="button" className="secondary sm" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
|
||||
<button type="button" className="secondary sm" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
|
||||
{isAdmin && (
|
||||
<button type="button" className="danger" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
|
||||
<button type="button" className="danger sm" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3>Beschikbare haltes</h3>
|
||||
<h3 style={{ marginBottom: 0 }}>Beschikbare haltes</h3>
|
||||
<div className="available-stops-grid">
|
||||
{availableStops.map(stop => (
|
||||
<article
|
||||
@@ -246,82 +237,52 @@ export default function LinkStop({ token, user }) {
|
||||
draggable
|
||||
onDragStart={() => setDraggedItem({ type: 'available', id: stop.id })}
|
||||
onDragEnd={() => setDraggedItem(null)}
|
||||
onClick={() => {
|
||||
if (busLineId) {
|
||||
setStopId(String(stop.id))
|
||||
appendDraggedStop(stop)
|
||||
}
|
||||
}}
|
||||
onClick={() => { if (busLineId) { setStopId(String(stop.id)); appendDraggedStop(stop) } }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<strong>#{stop.id} {stop.name}</strong>
|
||||
<div className="muted">{stop.latitude}, {stop.longitude}</div>
|
||||
<div className="muted">Sleep of klik om toe te voegen</div>
|
||||
<div className="faint">Sleep of klik om toe te voegen</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3>Link a Stop to Line</h3>
|
||||
{isAdmin ? (
|
||||
<form onSubmit={link} className="stack-form">
|
||||
<div className="form-row">
|
||||
<select value={stopId} onChange={e=>setStopId(e.target.value)}>
|
||||
<option value="">Select stop</option>
|
||||
{availableStops.map(stop => (
|
||||
<option key={stop.id} value={stop.id}>
|
||||
#{stop.id} {stop.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={`Next order: ${nextOrder}`} readOnly />
|
||||
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<button type="submit">Link Stop</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>You must be admin to link stops.</div>
|
||||
)}
|
||||
{message && <div className="message">{message}</div>}
|
||||
<div className="stack-form">
|
||||
<h3 style={{ marginTop: 0 }}>Halte koppelen aan lijn</h3>
|
||||
{isAdmin ? (
|
||||
<form onSubmit={link} className="stack-form" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||
<div className="form-row">
|
||||
<select value={stopId} onChange={e => setStopId(e.target.value)}>
|
||||
<option value="">Selecteer halte</option>
|
||||
{availableStops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||
</select>
|
||||
<input value={`Volgende: ${nextOrder}`} readOnly className="no-grow" />
|
||||
<input placeholder="ETA minuten (optioneel)" value={eta} onChange={e => setEta(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<button type="submit" className="no-grow">Halte koppelen</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="muted">Alleen beheerders kunnen haltes koppelen.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && <div className={`message ${messageType}`}>{message}</div>}
|
||||
|
||||
{deleteConfirm && (
|
||||
<div style={overlay}>
|
||||
<div style={modal}>
|
||||
<h3>Remove Stop from Line?</h3>
|
||||
<p><strong>{deleteConfirm.name}</strong></p>
|
||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will remove the stop from the route and re-order the remaining stops.</p>
|
||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal title="Halte verwijderen uit lijn?" onClose={() => setDeleteConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
|
||||
</>
|
||||
}>
|
||||
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> uit deze lijn wilt verwijderen?</p>
|
||||
<p className="faint">De halte wordt uit de route gehaald en de resterende stops worden opnieuw geordend.</p>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { authClient } from '../api'
|
||||
import { IconBusFront } from './icons'
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [userCode, setUserCode] = useState('')
|
||||
@@ -14,29 +15,51 @@ export default function Login({ onLogin }) {
|
||||
try {
|
||||
const res = await authClient.post('/login', { userCode, password })
|
||||
const token = res.data.token
|
||||
// fetch /me to get payload
|
||||
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
onLogin(token, me.data.user)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'login_failed')
|
||||
} finally { setLoading(false) }
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Admin Login</h2>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
User Code
|
||||
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
|
||||
{error && <div className="error">{error}</div>}
|
||||
</form>
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-brand">
|
||||
<div className="brand-mark"><IconBusFront size={26} /></div>
|
||||
<div>
|
||||
<h1>Transit Beheer</h1>
|
||||
<p>Operationeel dashboard voor netwerkbeheer</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
Gebruikercode
|
||||
<input
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
placeholder="Bijv. 1234"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wachtwoord
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="auth-submit" disabled={loading}>
|
||||
{loading ? 'Bezig met inloggen…' : 'Inloggen'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="auth-footer">Alleen beheerders hebben toegang</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Modal({ title, onClose, children, footer, wide }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className={`modal ${wide ? 'modal-wide' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<header className="modal-header">
|
||||
<h3>{title}</h3>
|
||||
<button type="button" className="icon-btn" onClick={onClose} aria-label="Sluiten">✕</button>
|
||||
</header>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <footer className="modal-footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { errorLogClient, transitClient } from '../api'
|
||||
import TimetableDisplay from './TimetableDisplay'
|
||||
import { IconAlert, IconBusFront, IconCalendar, IconMapPin, IconRoute } from './icons'
|
||||
|
||||
export default function Overview({ token }) {
|
||||
const client = transitClient(token)
|
||||
const [stats, setStats] = useState({ stops: null, lines: null, buses: null, drivers: null, logs: null })
|
||||
const [drivers, setDrivers] = useState([])
|
||||
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
|
||||
const [driverFilter, setDriverFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [stops, lines, buses, shifts] = await Promise.all([
|
||||
client.get('/admin/stops'),
|
||||
client.get('/admin/bus-lines'),
|
||||
client.get('/admin/fleet/buses'),
|
||||
client.get('/admin/shift-drivers'),
|
||||
])
|
||||
let logs = null
|
||||
try {
|
||||
const res = await errorLogClient(token).get('/error_log', { params: { limit: 1 } })
|
||||
logs = res.data.errors?.length ?? null
|
||||
} catch (_) {
|
||||
logs = null
|
||||
}
|
||||
setStats({
|
||||
stops: stops.data.stops?.length ?? null,
|
||||
lines: lines.data.busLines?.length ?? null,
|
||||
buses: buses.data.buses?.length ?? null,
|
||||
drivers: shifts.data.drivers?.length ?? null,
|
||||
logs,
|
||||
})
|
||||
setDrivers(shifts.data.drivers || [])
|
||||
} catch (_) {
|
||||
// stats stay null; individual sections handle real errors
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Haltes', value: stats.stops, icon: IconMapPin, tone: 'primary' },
|
||||
{ label: 'Buslijnen', value: stats.lines, icon: IconRoute, tone: 'accent' },
|
||||
{ label: 'Voertuigen', value: stats.buses, icon: IconBusFront, tone: 'success' },
|
||||
{ label: 'Chauffeurs', value: stats.drivers, icon: IconCalendar, tone: 'warning' },
|
||||
{ label: 'Foutmeldingen', value: stats.logs, icon: IconAlert, tone: 'danger' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="stats-grid">
|
||||
{statCards.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<article className="stat-card" key={card.label}>
|
||||
<div className={`stat-icon ${card.tone}`}><Icon size={22} /></div>
|
||||
<div>
|
||||
<div className="stat-value">{card.value === null ? '…' : card.value}</div>
|
||||
<div className="stat-label">{card.label}</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<TimetableDisplay
|
||||
client={client}
|
||||
overviewDate={overviewDate}
|
||||
setOverviewDate={setOverviewDate}
|
||||
selectedDriverFilter={driverFilter}
|
||||
setSelectedDriverFilter={setDriverFilter}
|
||||
drivers={drivers}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,33 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { transitClient } from "../api";
|
||||
import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
|
||||
import FixedScheduleEditor from "./FixedScheduleEditor";
|
||||
|
||||
export default function ScheduleMaker({ token }) {
|
||||
export default function ScheduleMaker({ token, user }) {
|
||||
const client = transitClient(token);
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
const [tab, setTab] = useState("weekly");
|
||||
const [weeklySchedules, setWeeklySchedules] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [busLines, setBusLines] = useState([]);
|
||||
const [buses, setBuses] = useState([]);
|
||||
const [drivers, setDrivers] = useState([]);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const loadSchedules = async () => {
|
||||
const loadWeekly = async () => {
|
||||
try {
|
||||
const res = await client.get("/admin/weekly-schedules");
|
||||
setSchedules(res.data.schedules || []);
|
||||
setWeeklySchedules(res.data.schedules || []);
|
||||
} catch (err) {
|
||||
setMessage("Could not load weekly schedules.");
|
||||
setMessage("Kon wekelijkse diensten niet laden.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const res = await client.get("/admin/schedules");
|
||||
setTemplates(res.data.schedules || []);
|
||||
} catch (err) {
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +36,16 @@ export default function ScheduleMaker({ token }) {
|
||||
const res = await client.get("/admin/bus-lines");
|
||||
setBusLines(res.data.busLines || []);
|
||||
} catch (err) {
|
||||
setMessage("Could not load bus lines.");
|
||||
setMessage("Kon buslijnen niet laden.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadBuses = async () => {
|
||||
try {
|
||||
const res = await client.get("/admin/fleet/buses");
|
||||
setBuses(res.data.buses || []);
|
||||
} catch (err) {
|
||||
setBuses([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,30 +54,57 @@ export default function ScheduleMaker({ token }) {
|
||||
const res = await client.get("/admin/shift-drivers");
|
||||
setDrivers(res.data.drivers || []);
|
||||
} catch (err) {
|
||||
setMessage("Could not load drivers.");
|
||||
setMessage("Kon chauffeurs niet laden.");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSchedules();
|
||||
loadWeekly();
|
||||
loadTemplates();
|
||||
loadBusLines();
|
||||
loadBuses();
|
||||
loadDrivers();
|
||||
}, []);
|
||||
|
||||
const handleScheduleChange = async () => {
|
||||
await loadSchedules();
|
||||
const handleScheduleChange = () => {
|
||||
loadWeekly();
|
||||
loadTemplates();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WeeklyScheduleEditor
|
||||
client={client}
|
||||
busLines={busLines}
|
||||
drivers={drivers}
|
||||
schedules={schedules}
|
||||
onChange={handleScheduleChange}
|
||||
/>
|
||||
{message && <div className="message">{message}</div>}
|
||||
<div className="stack">
|
||||
<div className="segmented">
|
||||
<button type="button" className={tab === "weekly" ? "active" : ""} onClick={() => setTab("weekly")}>
|
||||
Wekelijks schema
|
||||
</button>
|
||||
<button type="button" className={tab === "fixed" ? "active" : ""} onClick={() => setTab("fixed")}>
|
||||
Vaste diensten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "weekly" ? (
|
||||
<WeeklyScheduleEditor
|
||||
client={client}
|
||||
busLines={busLines}
|
||||
drivers={drivers}
|
||||
schedules={weeklySchedules}
|
||||
onChange={handleScheduleChange}
|
||||
/>
|
||||
) : (
|
||||
<FixedScheduleEditor
|
||||
client={client}
|
||||
user={user}
|
||||
scheduleTemplates={templates}
|
||||
busLines={busLines}
|
||||
buses={buses}
|
||||
drivers={drivers}
|
||||
message={message}
|
||||
setMessage={setMessage}
|
||||
onScheduleChange={handleScheduleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "weekly" && message && <div className="message">{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
import Modal from './Modal'
|
||||
|
||||
export default function StopsManager({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
@@ -8,17 +9,19 @@ export default function StopsManager({ token, user }) {
|
||||
const [lat, setLat] = useState('')
|
||||
const [lon, setLon] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [messageType, setMessageType] = useState('')
|
||||
const [isAdmin] = useState(Boolean(user?.isAdmin))
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||
const [editingStop, setEditingStop] = useState(null)
|
||||
|
||||
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||
|
||||
const loadStops = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/stops')
|
||||
setStops(res.data.stops || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,38 +31,50 @@ export default function StopsManager({ token, user }) {
|
||||
|
||||
const createStop = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
const res = await client.post('/admin/stops', {
|
||||
name,
|
||||
latitude: parseFloat(lat),
|
||||
longitude: parseFloat(lon)
|
||||
})
|
||||
setMessage('Stop created: #' + res.data.stop.id)
|
||||
notify('Halte aangemaakt: #' + res.data.stop.id)
|
||||
setName(''); setLat(''); setLon('')
|
||||
await loadStops()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
if (err.response?.status === 401) {
|
||||
setMessage('Unauthorized: invalid token')
|
||||
} else if (err.response?.status === 403) {
|
||||
setMessage('Forbidden: you are not an admin')
|
||||
} else {
|
||||
setMessage('Error: ' + errMsg)
|
||||
}
|
||||
if (err.response?.status === 401) notify('Unauthorized: invalid token', 'error')
|
||||
else if (err.response?.status === 403) notify('Forbidden: you are not an admin', 'error')
|
||||
else notify('Error: ' + errMsg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteStop = async (stopId, stopName) => {
|
||||
setMessage(null)
|
||||
notify(null)
|
||||
try {
|
||||
await client.delete(`/admin/stops/${stopId}`)
|
||||
setMessage(`Halte "${stopName}" verwijderd`)
|
||||
notify(`Halte "${stopName}" verwijderd`)
|
||||
setDeleteConfirm(null)
|
||||
await loadStops()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
setMessage('Error: ' + errMsg)
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveStop = async (e) => {
|
||||
e.preventDefault()
|
||||
notify(null)
|
||||
try {
|
||||
await client.patch(`/admin/stops/${editingStop.id}`, {
|
||||
name: editingStop.name,
|
||||
latitude: parseFloat(editingStop.latitude),
|
||||
longitude: parseFloat(editingStop.longitude),
|
||||
})
|
||||
notify('Halte bijgewerkt')
|
||||
setEditingStop(null)
|
||||
await loadStops()
|
||||
} catch (err) {
|
||||
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,122 +82,72 @@ export default function StopsManager({ token, user }) {
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Stops</h2>
|
||||
<p>Maak herbruikbare haltes aan.</p>
|
||||
<h2>Haltes</h2>
|
||||
<p>Maak herbruikbare haltes aan en bewerk hun coördinaten.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadStops}>Refresh</button>
|
||||
<button className="secondary" onClick={loadStops}>Ververs</button>
|
||||
</div>
|
||||
|
||||
<div className="list-grid">
|
||||
{stops.map(stop => (
|
||||
<article className="mini-card" key={stop.id}>
|
||||
<strong>#{stop.id} {stop.name}</strong>
|
||||
<div>{stop.latitude}, {stop.longitude}</div>
|
||||
<strong>Halte #{stop.id} · {stop.name}</strong>
|
||||
<div className="meta-line"><span className="badge badge-primary">#</span> {stop.latitude}, {stop.longitude}</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => setDeleteConfirm(stop)}
|
||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => setEditingStop(stop)}
|
||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<div className="card-actions">
|
||||
<button className="secondary" onClick={() => setEditingStop(stop)}>Bewerken</button>
|
||||
<button className="danger" onClick={() => setDeleteConfirm(stop)}>Verwijderen</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<form onSubmit={createStop} className="stack-form">
|
||||
<div className="form-row">
|
||||
<input placeholder="Name" value={name} onChange={e=>setName(e.target.value)} />
|
||||
<input placeholder="Latitude" value={lat} onChange={e=>setLat(e.target.value)} />
|
||||
<input placeholder="Longitude" value={lon} onChange={e=>setLon(e.target.value)} />
|
||||
<button type="submit">Create Stop</button>
|
||||
<input placeholder="Naam halte" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="Latitude" value={lat} onChange={e => setLat(e.target.value)} />
|
||||
<input placeholder="Longitude" value={lon} onChange={e => setLon(e.target.value)} />
|
||||
<button type="submit" className="no-grow">Halte aanmaken</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>You must be admin to create stops.</div>
|
||||
<div className="muted">Alleen beheerders kunnen haltes aanmaken.</div>
|
||||
)}
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
|
||||
{message && <div className={`message ${messageType}`}>{message}</div>}
|
||||
|
||||
{deleteConfirm && (
|
||||
<div style={overlay}>
|
||||
<div style={modal}>
|
||||
<h3>Delete Stop?</h3>
|
||||
<p><strong>{deleteConfirm.name}</strong></p>
|
||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>This cannot be undone.</p>
|
||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal title="Halte verwijderen?" onClose={() => setDeleteConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
|
||||
</>
|
||||
}>
|
||||
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> wilt verwijderen?</p>
|
||||
<p className="faint">Dit kan niet ongedaan worden gemaakt.</p>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<Modal title="Halte bewerken" onClose={() => setEditingStop(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="secondary" onClick={() => setEditingStop(null)}>Annuleren</button>
|
||||
<button onClick={saveStop}>Opslaan</button>
|
||||
</>
|
||||
}>
|
||||
<div className="form-row">
|
||||
<input value={editingStop.name} onChange={e => setEditingStop({ ...editingStop, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<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>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}
|
||||
}
|
||||
@@ -84,19 +84,22 @@ const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDrive
|
||||
<h3>Dagelijks Overzicht</h3>
|
||||
<p>Alle geplande diensten voor de geselecteerde dag</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="date"
|
||||
value={overviewDate}
|
||||
onChange={e => setOverviewDate(e.target.value)}
|
||||
className="no-grow"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
|
||||
<button type="button" className="secondary no-grow" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
|
||||
Vandaag
|
||||
</button>
|
||||
<select
|
||||
value={selectedDriverFilter}
|
||||
onChange={e => setSelectedDriverFilter(e.target.value)}
|
||||
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
className="no-grow"
|
||||
style={{ minWidth: 170 }}
|
||||
>
|
||||
<option value="">Alle chauffeurs</option>
|
||||
{drivers.map(d => (
|
||||
@@ -148,46 +151,37 @@ const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDrive
|
||||
{/* Expandable stops list */}
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-expand"
|
||||
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
width: '100%',
|
||||
padding: '0.4rem',
|
||||
fontSize: '0.85rem',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
|
||||
</button>
|
||||
|
||||
{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 var(--border)' }}>
|
||||
{(() => {
|
||||
const lineId = schedule.busLineId
|
||||
const cached = routeStopsByLine[lineId]
|
||||
if (!cached) {
|
||||
loadRouteStops(lineId)
|
||||
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
|
||||
return <p className="faint">Laden haltes…</p>
|
||||
}
|
||||
|
||||
if (cached.length === 0) {
|
||||
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
|
||||
return <p className="faint">Geen haltes gekoppeld</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<table style={{ width: '100%', fontSize: '0.8rem' }}>
|
||||
<table className="stop-table">
|
||||
<tbody>
|
||||
{cached.map(rs => {
|
||||
const departureDate = new Date(schedule.startTime)
|
||||
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
|
||||
return (
|
||||
<tr key={rs.id}>
|
||||
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
|
||||
<td style={{ flex: 1 }}>{rs.name}</td>
|
||||
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
|
||||
<td style={{ width: 24 }}><strong>{rs.stopOrder}.</strong></td>
|
||||
<td>{rs.name}</td>
|
||||
<td style={{ textAlign: 'right', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{formatTime(departureDate.toISOString())}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
const DAY_GROUPS = [
|
||||
{ value: "weekdays", label: "Weekdays", days: [1, 2, 3, 4, 5] },
|
||||
{ value: "saturday", label: "Saturday", days: [6] },
|
||||
{ value: "sunday", label: "Sunday", days: [0] },
|
||||
{ value: "weekdays", label: "Weekdagen", days: [1, 2, 3, 4, 5] },
|
||||
{ value: "saturday", label: "Zaterdag", days: [6] },
|
||||
{ value: "sunday", label: "Zondag", days: [0] },
|
||||
];
|
||||
|
||||
const dayLabel = (day) =>
|
||||
[
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
][day];
|
||||
["Zondag", "Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag"][day];
|
||||
|
||||
export default function WeeklyScheduleEditor({
|
||||
client,
|
||||
@@ -30,8 +22,11 @@ export default function WeeklyScheduleEditor({
|
||||
const [newStartTime, setNewStartTime] = useState("");
|
||||
const [driverId, setDriverId] = useState("");
|
||||
const [message, setMessage] = useState(null);
|
||||
const [messageType, setMessageType] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const notify = (text, type = "") => { setMessage(text); setMessageType(type) };
|
||||
|
||||
const groupedSchedules = useMemo(() => {
|
||||
return schedules.reduce((groups, schedule) => {
|
||||
const key =
|
||||
@@ -47,9 +42,9 @@ export default function WeeklyScheduleEditor({
|
||||
|
||||
const createSchedules = async (event) => {
|
||||
event.preventDefault();
|
||||
setMessage(null);
|
||||
notify(null);
|
||||
if (!lineId || startTimes.length === 0) {
|
||||
setMessage("Choose a line and add at least one departure time.");
|
||||
notify("Kies een lijn en voeg minstens één vertrektijd toe.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,14 +57,12 @@ export default function WeeklyScheduleEditor({
|
||||
startTimes,
|
||||
driverId: driverId ? Number(driverId) : null,
|
||||
});
|
||||
setMessage(
|
||||
`${response.data.createdCount} departures created for ${group.label}.`,
|
||||
);
|
||||
notify(`${response.data.createdCount} vertrekken aangemaakt voor ${group.label}.`);
|
||||
setStartTimes(["08:00"]);
|
||||
setNewStartTime("");
|
||||
onChange();
|
||||
} catch (error) {
|
||||
setMessage(error.response?.data?.error || "Could not save the schedule.");
|
||||
notify(error.response?.data?.error || "Kon het schema niet opslaan.", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -90,26 +83,21 @@ export default function WeeklyScheduleEditor({
|
||||
await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, {
|
||||
driverId: value ? Number(value) : null,
|
||||
});
|
||||
setMessage("Driver assignment updated.");
|
||||
notify("Chauffeurstoewijzing bijgewerkt.");
|
||||
onChange();
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error.response?.data?.error ||
|
||||
"Could not update the driver assignment.",
|
||||
);
|
||||
notify(error.response?.data?.error || "Kon de toewijzing niet bijwerken.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const removeSchedule = async (scheduleId) => {
|
||||
if (!window.confirm("Delete this weekly schedule?")) return;
|
||||
if (!window.confirm("Dit wekelijkse schema verwijderen?")) return;
|
||||
try {
|
||||
await client.delete(`/admin/weekly-schedules/${scheduleId}`);
|
||||
setMessage("Weekly schedule deleted.");
|
||||
notify("Wekelijks schema verwijderd.");
|
||||
onChange();
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error.response?.data?.error || "Could not delete the schedule.",
|
||||
);
|
||||
notify(error.response?.data?.error || "Kon het schema niet verwijderen.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,163 +105,102 @@ export default function WeeklyScheduleEditor({
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h3>Weekly schedules</h3>
|
||||
<h3>Wekelijkse schema's</h3>
|
||||
<p>
|
||||
Add multiple daily departures for weekdays, Saturday, or Sunday,
|
||||
then assign a driver to the generated templates.
|
||||
Voeg meerdere dagelijkse vertrekken toe voor weekdagen, zaterdag of zondag en wijs
|
||||
daarna een chauffeur toe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={createSchedules} className="stack-form">
|
||||
<div className="form-row">
|
||||
<select
|
||||
value={lineId}
|
||||
onChange={(event) => setLineId(event.target.value)}
|
||||
>
|
||||
<option value="">Choose bus line</option>
|
||||
<select value={lineId} onChange={(event) => setLineId(event.target.value)}>
|
||||
<option value="">Kies buslijn</option>
|
||||
{busLines.map((line) => (
|
||||
<option key={line.id} value={line.id}>
|
||||
Line {line.lineNumber} ({line.startStop} to {line.endStop})
|
||||
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
>
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
{DAY_GROUPS.map((group) => (
|
||||
<option key={group.value} value={group.value}>
|
||||
{group.label}
|
||||
</option>
|
||||
<option key={group.value} value={group.value}>{group.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: "center" }}>
|
||||
<div className="form-row">
|
||||
<input
|
||||
type="time"
|
||||
value={newStartTime}
|
||||
onChange={(event) => setNewStartTime(event.target.value)}
|
||||
aria-label="New departure time"
|
||||
aria-label="Nieuwe vertrektijd"
|
||||
className="no-grow"
|
||||
style={{ minWidth: 130 }}
|
||||
/>
|
||||
<button type="button" className="secondary" onClick={addStartTime}>
|
||||
Add departure
|
||||
<button type="button" className="secondary no-grow" onClick={addStartTime}>
|
||||
Vertrektijd toevoegen
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 minw-0" style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
{startTimes.map((time) => (
|
||||
<span
|
||||
key={time}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
gap: "0.4rem",
|
||||
alignItems: "center",
|
||||
padding: "0.4rem 0.6rem",
|
||||
border: "1px solid #c7dcff",
|
||||
borderRadius: "999px",
|
||||
background: "#eaf2ff",
|
||||
}}
|
||||
>
|
||||
<span key={time} className="pill-chip">
|
||||
{time}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStartTime(time)}
|
||||
aria-label={`Remove departure ${time}`}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
<button type="button" onClick={() => removeStartTime(time)} aria-label={`Verwijder ${time}`}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={driverId}
|
||||
onChange={(event) => setDriverId(event.target.value)}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<select value={driverId} onChange={(event) => setDriverId(event.target.value)}>
|
||||
<option value="">Niet toegewezen</option>
|
||||
{drivers.map((driver) => (
|
||||
<option key={driver.id} value={driver.id}>
|
||||
{driver.name}
|
||||
</option>
|
||||
<option key={driver.id} value={driver.id}>{driver.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : `Create ${startTimes.length} departures`}
|
||||
<button type="submit" className="no-grow" disabled={saving}>
|
||||
{saving ? "Opslaan…" : `Aanmaken (${startTimes.length} vertrekken)`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
{message && <div className={`message ${messageType}`}>{message}</div>}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: "1rem",
|
||||
marginTop: "1.5rem",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div className="weekly-panels">
|
||||
{DAY_GROUPS.map((group) => (
|
||||
<section
|
||||
key={group.value}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
border: "1px solid #dbe3ef",
|
||||
borderRadius: "8px",
|
||||
background: "#f8fafc",
|
||||
padding: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ marginTop: 0 }}>{group.label}</h4>
|
||||
<section key={group.value} className="weekly-panel">
|
||||
<h4>{group.label}</h4>
|
||||
{(groupedSchedules[group.value] || []).length === 0 ? (
|
||||
<p>No schedules configured.</p>
|
||||
<p className="muted">Nog geen schema geconfigureerd.</p>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
{groupedSchedules[group.value].map((schedule) => (
|
||||
<article key={schedule.id} className="schedule-card">
|
||||
<div className="schedule-header">
|
||||
<strong>Line {schedule.lineNumber}</strong>
|
||||
<strong>Lijn {schedule.lineNumber}</strong>
|
||||
<span className="schedule-time">
|
||||
{String(schedule.startTime).slice(0, 5)} -{" "}
|
||||
{String(schedule.endTime).slice(0, 5)}
|
||||
{String(schedule.startTime).slice(0, 5)} – {String(schedule.endTime).slice(0, 5)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="schedule-details">
|
||||
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
|
||||
{schedule.endStop}
|
||||
<div>{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} → {schedule.endStop}</div>
|
||||
</div>
|
||||
<select
|
||||
value={schedule.driverId || ""}
|
||||
onChange={(event) =>
|
||||
assignDriver(schedule.id, event.target.value)
|
||||
}
|
||||
style={{ width: "100%", marginTop: "0.75rem" }}
|
||||
onChange={(event) => assignDriver(schedule.id, event.target.value)}
|
||||
style={{ width: "100%", marginTop: "10px" }}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">Niet toegewezen</option>
|
||||
{drivers.map((driver) => (
|
||||
<option key={driver.id} value={driver.id}>
|
||||
{driver.name}
|
||||
</option>
|
||||
<option key={driver.id} value={driver.id}>{driver.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
className="danger block"
|
||||
style={{ marginTop: "8px" }}
|
||||
onClick={() => removeSchedule(schedule.id)}
|
||||
style={{ width: "100%", marginTop: "0.5rem" }}
|
||||
>
|
||||
Delete
|
||||
Verwijderen
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
@@ -284,4 +211,4 @@ export default function WeeklyScheduleEditor({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
|
||||
const Svg = ({ size = 18, children, ...props }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconGrid = (p) => (
|
||||
<Svg {...p}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconMapPin = (p) => (
|
||||
<Svg {...p}>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 1 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconRoute = (p) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="6" cy="19" r="3" />
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<path d="M9 19h3a4 4 0 0 0 0-8H9a4 4 0 0 1 0-8h6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconLink = (p) => (
|
||||
<Svg {...p}>
|
||||
<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
|
||||
<path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconBus = (p) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 6a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||
<path d="M4 10h16" />
|
||||
<path d="M8 20v2M16 20v2" />
|
||||
<circle cx="8" cy="14" r="1" />
|
||||
<circle cx="16" cy="14" r="1" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconCalendar = (p) => (
|
||||
<Svg {...p}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconAlert = (p) => (
|
||||
<Svg {...p}>
|
||||
<path d="M10.3 4.2 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z" />
|
||||
<path d="M12 9v4M12 17h.01" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconLogout = (p) => (
|
||||
<Svg {...p}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5M21 12H9" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconBusFront = (p) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 8V7a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v1" />
|
||||
<path d="M4 8h16v6H4zM8 17v3M16 17v3" />
|
||||
<rect x="4" y="14" width="16" height="3" rx="1" />
|
||||
<circle cx="8" cy="11" r="0.6" />
|
||||
<circle cx="16" cy="11" r="0.6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconClock = (p) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</Svg>
|
||||
)
|
||||
+1036
-173
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user