Redesign admin UI as transit operations command center with sidebar navigation, design system, and dashboard

This commit is contained in:
Sven laptop
2026-09-24 16:54:37 +02:00
parent b766725b3b
commit 2ae1720383
16 changed files with 1905 additions and 899 deletions
+9 -2
View File
@@ -1,9 +1,16 @@
<!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>
+86 -23
View File
@@ -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 className="userbox">
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
</div>
<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>
<div className="hero">
<div>
<h2>Fleet, lijnen en stops</h2>
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
</div>
</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 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>
</div>
)
}
+85 -133
View File
@@ -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({
<div className="card-actions" style={{ gridTemplateColumns: '1fr 1fr' }}>
<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,
startStop: stopIdByName(line.startStop) || line.startStop,
endStop: stopIdByName(line.endStop) || line.endStop,
estimatedDurationMinutes: line.estimatedDurationMinutes,
description: line.description || '',
})}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Edit
</button>
</>
})}>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">
<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 className="form-row">
<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>
))}
<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="">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 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>
</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)',
}
+48 -27
View File
@@ -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,40 +55,54 @@ 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.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) => (
<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>
<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>
{!logs.length && !message && (
<div className="muted">Nog geen foutmeldingen gevonden.</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
+77 -93
View File
@@ -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,72 +111,46 @@ 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">
<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 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>
<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>
</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>
)
+72 -111
View File
@@ -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>
<div className="stack-form">
<h3 style={{ marginTop: 0 }}>Halte koppelen aan lijn</h3>
{isAdmin ? (
<form onSubmit={link} className="stack-form">
<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="">Select stop</option>
{availableStops.map(stop => (
<option key={stop.id} value={stop.id}>
#{stop.id} {stop.name}
</option>
))}
<option value="">Selecteer halte</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)} />
<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">Link Stop</button>
<button type="submit" className="no-grow">Halte koppelen</button>
</div>
</form>
) : (
<div>You must be admin to link stops.</div>
<div className="muted">Alleen beheerders kunnen haltes koppelen.</div>
)}
{message && <div className="message">{message}</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)',
}
+32 -9
View File
@@ -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>
<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>
User Code
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
Gebruikercode
<input
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Bijv. 1234"
autoFocus
/>
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
Wachtwoord
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
</label>
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
{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>
)
}
+16
View File
@@ -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>
)
}
+79
View File
@@ -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}
/>
</>
)
}
+62 -13
View File
@@ -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>
<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={schedules}
schedules={weeklySchedules}
onChange={handleScheduleChange}
/>
{message && <div className="message">{message}</div>}
) : (
<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>
);
}
+64 -99
View File
@@ -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="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">Create Stop</button>
<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">
<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 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>
<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>
</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)',
}
+14 -20
View File
@@ -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>
)
})}
+56 -129
View File
@@ -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>
))}
+97
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff