create admin_ui

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 10:55:31 +02:00
parent 2845f442a3
commit 8cf3fe98cb
40 changed files with 5534 additions and 693 deletions
+50
View File
@@ -0,0 +1,50 @@
import React, { useState, useEffect } from 'react'
import Login from './components/Login'
import StopsManager from './components/StopsManager'
import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager'
export default function App() {
const [token, setToken] = useState(localStorage.getItem('token'))
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
useEffect(() => {
if (token) localStorage.setItem('token', token)
else localStorage.removeItem('token')
}, [token])
useEffect(() => {
if (user) localStorage.setItem('user', JSON.stringify(user))
else localStorage.removeItem('user')
}, [user])
if (!token) return <Login onLogin={(t,u) => { setToken(t); setUser(u); }} />
return (
<div className="app">
<header className="topbar">
<div>
<p className="eyebrow">Transit operations</p>
<h1>Admin control center</h1>
</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>
</div>
</div>
<main>
<StopsManager token={token} user={user} />
<BusLinesManager token={token} user={user} />
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
</main>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import axios from 'axios'
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
const AUTH_API = RUNTIME.VITE_AUTH_API || import.meta.env.VITE_AUTH_API || 'http://localhost:5000'
const TRANSIT_API = RUNTIME.VITE_TRANSIT_API || import.meta.env.VITE_TRANSIT_API || 'http://localhost:5001'
export const authClient = axios.create({ baseURL: AUTH_API })
export const transitClient = (token) => axios.create({
baseURL: TRANSIT_API,
headers: { Authorization: `Bearer ${token}` }
})
@@ -0,0 +1,90 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function BusLinesManager({ token, user }) {
const client = transitClient(token)
const [busLines, setBusLines] = useState([])
const [lineNumber, setLineNumber] = useState('')
const [startStop, setStartStop] = useState('')
const [endStop, setEndStop] = useState('')
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
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))
}
}
useEffect(() => {
loadBusLines()
}, [])
const createBusLine = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post('/admin/bus-lines', {
lineNumber: Number(lineNumber),
startStop,
endStop,
estimatedDurationMinutes: Number(estimatedDurationMinutes),
description,
})
setLineNumber('')
setStartStop('')
setEndStop('')
setEstimatedDurationMinutes('')
setDescription('')
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
await loadBusLines()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Buslijnen</h2>
<p>Maak en bewerk buslijnen.</p>
</div>
<button className="secondary" onClick={loadBusLines}>Refresh</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}
</article>
))}
</div>
{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="Start stop" value={startStop} onChange={e => setStartStop(e.target.value)} />
<input placeholder="End stop" value={endStop} onChange={e => setEndStop(e.target.value)} />
</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>
</div>
</form>
) : <div>You must be admin to create bus lines.</div>}
{message && <div className="message">{message}</div>}
</section>
)
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function FleetManager({ token, user }) {
const client = transitClient(token)
const [buses, setBuses] = useState([])
const [name, setName] = useState('')
const [seatCapacity, setSeatCapacity] = useState('')
const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null)
const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode))
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))
}
}
useEffect(() => {
loadBuses()
}, [])
const createBus = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post('/admin/fleet/buses', {
name,
seatCapacity: Number(seatCapacity),
licensePlate,
})
setName('')
setSeatCapacity('')
setLicensePlate('')
setMessage('Bus created: #' + (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.')
return
}
setMessage('Error: ' + details)
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Fleet</h2>
<p>Beheer voertuigen voor de vloot.</p>
</div>
<button className="secondary" onClick={loadBuses}>Refresh</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>
</article>
))}
</div>
{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>
</div>
</form>
) : (
<div className="muted">Fleet create is read-only on this database user.</div>
)}
{message && <div className="message">{message}</div>}
</section>
)
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function LinkStop({ token, user }) {
const client = transitClient(token)
const [busLines, setBusLines] = useState([])
const [busLineId, setBusLineId] = useState('')
const [stopId, setStopId] = useState('')
const [order, setOrder] = useState('')
const [eta, setEta] = useState('')
const [message, setMessage] = useState(null)
const [stops, setStops] = useState([])
const isAdmin = Boolean(user?.userCode)
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))
}
}
const loadStopsForLine = async (lineId = busLineId) => {
if (!lineId) return
setMessage(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))
}
}
useEffect(() => {
loadBusLines()
}, [])
const link = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
stopId: parseInt(stopId,10),
stopOrder: parseInt(order,10),
estimatedArrivalMinutes: eta ? parseInt(eta,10) : null
})
setMessage('Linked: ' + res.data.routeStop.id)
await loadStopsForLine()
} catch (err) {
if (err.response?.status === 403) setMessage('Forbidden: not admin')
else setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Stops per lijn</h2>
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
</div>
<button className="secondary" onClick={() => loadStopsForLine()}>Refresh</button>
</div>
<div className="form-row">
<input
placeholder="Bus line ID"
value={busLineId}
onChange={e => {
setBusLineId(e.target.value)
}}
/>
<button type="button" onClick={() => loadStopsForLine()}>Load Stops</button>
</div>
{busLines.length > 0 && (
<div className="muted" style={{ marginBottom: 10 }}>
Known lines: {busLines.map(line => `#${line.id} (lijn ${line.lineNumber})`).join(', ')}
</div>
)}
<ul>
{stops.map(s => (
<li key={s.id}>{s.order}. {s.name} ({s.latitude}, {s.longitude})</li>
))}
</ul>
<h3>Link a Stop to Line</h3>
{isAdmin ? (
<form onSubmit={link} className="form-row">
<input placeholder="Stop ID" value={stopId} onChange={e=>setStopId(e.target.value)} />
<input placeholder="Stop Order" value={order} onChange={e=>setOrder(e.target.value)} />
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
<button type="submit">Link Stop</button>
</form>
) : (
<div>You must be admin to link stops.</div>
)}
{message && <div className="message">{message}</div>}
</section>
)
}
+61
View File
@@ -0,0 +1,61 @@
import React, { useState } from 'react'
import { authClient } from '../api'
export default function Login({ onLogin }) {
const [userCode, setUserCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const submit = async (e) => {
e.preventDefault()
setError(null)
setLoading(true)
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) }
}
const devToken = async () => {
setError(null)
setLoading(true)
try {
const res = await authClient.post('/dev/token', { userId: 1, userCode: 42 })
const token = res.data.token
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
onLogin(token, me.data.user)
} catch (err) {
setError(err.response?.data?.error || 'dev_token_failed')
} 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>
<p>Be sure `ADMIN_USER_CODES` contains your `userCode` for admin actions.</p>
<hr />
<div>
<button onClick={devToken} disabled={loading}>Get Dev Token (if available)</button>
<div style={{fontSize:12, marginTop:6}}>Uses `/dev/token` on the auth API for quick local testing.</div>
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function StopsManager({ token, user }) {
const client = transitClient(token)
const [stops, setStops] = useState([])
const [name, setName] = useState('')
const [lat, setLat] = useState('')
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
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))
}
}
useEffect(() => {
loadStops()
}, [])
const createStop = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post('/admin/stops', {
name,
latitude: parseFloat(lat),
longitude: parseFloat(lon)
})
setMessage('Stop created: #' + 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)
}
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Stops</h2>
<p>Maak herbruikbare haltes aan.</p>
</div>
<button className="secondary" onClick={loadStops}>Refresh</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>
</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>
</div>
</form>
) : (
<div>You must be admin to create stops.</div>
)}
{message && <div className="message">{message}</div>}
<p>Note: To view stops per line, use the Bus Line Stops tool below.</p>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+174
View File
@@ -0,0 +1,174 @@
body {
margin: 0;
color: #0f172a;
background:
radial-gradient(circle at top left, rgba(99, 102, 241, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
font-family: Inter, system-ui, Arial, sans-serif;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 28px 18px 48px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.topbar h1 {
margin: 4px 0 0;
font-size: 2.2rem;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #4f46e5;
font-size: 0.75rem;
font-weight: 700;
}
.userbox {
display: flex;
gap: 12px;
align-items: center;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 10px 14px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
}
.hero {
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(37, 99, 235, 0.88));
color: white;
border-radius: 24px;
padding: 24px 26px;
margin-bottom: 22px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.hero h2 {
margin: 0 0 6px;
font-size: 1.5rem;
}
.hero p,
.card p {
margin: 0;
color: inherit;
}
main {
display: grid;
gap: 18px;
}
.card,
.login {
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 22px;
padding: 18px;
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.07);
backdrop-filter: blur(14px);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 10px;
margin: 10px 0;
flex-wrap: wrap;
}
.stack-form {
margin-top: 10px;
}
input,
select,
button {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.45);
padding: 11px 12px;
font: inherit;
}
input,
select {
background: white;
min-width: 180px;
flex: 1;
}
button {
background: #111827;
color: white;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16);
}
button.secondary {
background: white;
color: #0f172a;
}
.message {
margin-top: 10px;
color: #047857;
font-weight: 600;
}
.error {
color: #b91c1c;
margin-top: 10px;
}
.list-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin: 10px 0 16px;
}
.mini-card {
background: rgba(248, 250, 252, 0.96);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px;
padding: 12px 14px;
}
.mini-card strong {
display: block;
margin-bottom: 6px;
}
.muted {
color: #64748b;
font-size: 0.95rem;
}
.login {
max-width: 540px;
margin: 60px auto;
}