admin UI UX update

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 12:57:53 +02:00
parent e5e6611cf5
commit d8caf51faf
8 changed files with 435 additions and 67 deletions
+8 -8
View File
@@ -61,8 +61,8 @@ docker build -t xtransit/admin_ui:latest .
```bash
docker run -p 5174:80 \
-e VITE_AUTH_API=http://localhost:5000 \
-e VITE_TRANSIT_API=http://localhost:5001 \
-e VITE_AUTH_API=/api/auth \
-e VITE_TRANSIT_API=/api/transit \
xtransit/admin_ui:latest
```
@@ -86,8 +86,8 @@ http://localhost:5174
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_AUTH_API` | Auth API URL (internal: http://auth_api:5000) | http://localhost:5000 |
| `VITE_TRANSIT_API` | Transit API URL (internal: http://transit_api:5001) | http://localhost:5001 |
| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 |
| `VITE_TRANSIT_API` | Transit API URL (same-origin path recommended in Docker) | http://localhost:5001 |
### Runtime Configuration
@@ -113,7 +113,7 @@ When running in Docker, environment variables are injected at runtime via `docke
### API Integration
The app communicates with:
- **Auth API** (/login, /me, /dev/token)
- **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
All requests include JWT Bearer token for authentication.
@@ -122,9 +122,9 @@ All requests include JWT Bearer token for authentication.
### Login Flow
1. User clicks "Get Dev Token" button (development only)
2. Client calls POST `/dev/token`
3. Receives JWT token with userId and userCode
1. User enters user code and password
2. Client calls POST `/login`
3. Receives JWT token with `userId` and `userCode`
4. JWT is stored in localStorage
5. All API requests include `Authorization: Bearer <token>`
+23 -2
View File
@@ -4,6 +4,7 @@ import { transitClient } from '../api'
export default function BusLinesManager({ token, user }) {
const client = transitClient(token)
const [busLines, setBusLines] = useState([])
const [stops, setStops] = useState([])
const [lineNumber, setLineNumber] = useState('')
const [startStop, setStartStop] = useState('')
const [endStop, setEndStop] = useState('')
@@ -21,8 +22,18 @@ export default function BusLinesManager({ token, user }) {
}
}
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(() => {
loadBusLines()
loadStops()
}, [])
const createBusLine = async (e) => {
@@ -73,8 +84,18 @@ export default function BusLinesManager({ token, user }) {
<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)} />
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
<option value="">Start stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.name}>#{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.name}>#{stop.id} {stop.name}</option>
))}
</select>
</div>
<div className="form-row">
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
+196 -27
View File
@@ -1,18 +1,21 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { transitClient } from '../api'
export default function LinkStop({ token, user }) {
const client = transitClient(token)
const [busLines, setBusLines] = useState([])
const [availableStops, setAvailableStops] = 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 [draggedItem, setDraggedItem] = useState(null)
const isAdmin = Boolean(user?.userCode)
const nextOrder = useMemo(() => stops.length + 1, [stops])
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
@@ -33,20 +36,43 @@ export default function LinkStop({ token, user }) {
}
}
const loadAvailableStops = async () => {
try {
const res = await client.get('/admin/stops')
setAvailableStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
const refreshBoard = async () => {
await Promise.all([
loadBusLines(),
loadAvailableStops(),
loadStopsForLine(),
])
}
useEffect(() => {
loadBusLines()
loadAvailableStops()
}, [])
useEffect(() => {
loadStopsForLine()
}, [busLineId])
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
stopId: parseInt(stopId, 10),
stopOrder: nextOrder,
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
})
setMessage('Linked: ' + res.data.routeStop.id)
setStopId('')
await loadStopsForLine()
} catch (err) {
if (err.response?.status === 403) setMessage('Forbidden: not admin')
@@ -54,6 +80,71 @@ export default function LinkStop({ token, user }) {
}
}
const appendDraggedStop = async (stopToLink) => {
if (!busLineId) {
setMessage('Select a bus line first')
return
}
setMessage(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}`)
await loadStopsForLine()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
const persistOrder = async (orderedStops) => {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops/reorder`, {
routeStopIds: orderedStops.map(stop => stop.id),
})
setStops(res.data.routeStops || orderedStops)
}
const reorderStops = async (fromId, toId) => {
if (!fromId || !toId || fromId === toId) return
const fromIndex = stops.findIndex(stop => stop.id === fromId)
const toIndex = stops.findIndex(stop => stop.id === toId)
if (fromIndex < 0 || toIndex < 0) return
const nextStops = [...stops]
const [moved] = nextStops.splice(fromIndex, 1)
nextStops.splice(toIndex, 0, moved)
const normalized = nextStops.map((stop, index) => ({ ...stop, order: index + 1 }))
setStops(normalized)
setMessage('Saving new route order...')
try {
await persistOrder(normalized)
setMessage('Route order updated')
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
await loadStopsForLine()
}
}
const moveStop = async (index, direction) => {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= stops.length) return
const nextStops = [...stops]
const [moved] = nextStops.splice(index, 1)
nextStops.splice(targetIndex, 0, moved)
const normalized = nextStops.map((stop, nextIndex) => ({ ...stop, order: nextIndex + 1 }))
setStops(normalized)
try {
await persistOrder(normalized)
setMessage('Route order updated')
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
await loadStopsForLine()
}
}
return (
<section className="card">
<div className="section-header">
@@ -61,39 +152,117 @@ export default function LinkStop({ token, user }) {
<h2>Stops per lijn</h2>
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
</div>
<button className="secondary" onClick={() => loadStopsForLine()}>Refresh</button>
<button className="secondary" onClick={refreshBoard}>Refresh</button>
</div>
<div className="form-row">
<input
placeholder="Bus line ID"
<select
value={busLineId}
onChange={e => {
setBusLineId(e.target.value)
}}
/>
<button type="button" onClick={() => loadStopsForLine()}>Load Stops</button>
onChange={e => setBusLineId(e.target.value)}
>
<option value="">Choose line</option>
{busLines.map(line => (
<option key={line.id} value={line.id}>
#{line.id} lijn {line.lineNumber} - {line.startStop} → {line.endStop}
</option>
))}
</select>
<button type="button" onClick={() => loadStopsForLine()} disabled={!busLineId}>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>
)}
<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.
</div>
<ul>
{stops.map(s => (
<li key={s.id}>{s.order}. {s.name} ({s.latitude}, {s.longitude})</li>
<div
className={`route-stops-board ${draggedItem?.type === 'available' ? 'drop-ready' : ''}`}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (draggedItem?.type === 'available') {
const stop = availableStops.find(item => Number(item.id) === Number(draggedItem.id))
if (stop) appendDraggedStop(stop)
}
setDraggedItem(null)
}}
>
{stops.map((s, index) => (
<article
className={`route-stop-item ${draggedItem?.type === 'route' && Number(draggedItem.id) === Number(s.id) ? 'dragging' : ''}`}
key={s.id}
draggable
onDragStart={() => setDraggedItem({ type: 'route', id: s.id })}
onDragEnd={() => setDraggedItem(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
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)
}
setDraggedItem(null)
}}
>
<div className="route-stop-handle" aria-hidden="true">⋮⋮</div>
<div className="route-stop-content">
<strong>{s.order}. {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>
</div>
</article>
))}
</ul>
</div>
<h3>Beschikbare haltes</h3>
<div className="available-stops-grid">
{availableStops.map(stop => (
<article
key={stop.id}
className={`mini-card available-stop ${draggedItem?.type === 'available' && Number(draggedItem.id) === Number(stop.id) ? 'dragging' : ''}`}
draggable
onDragStart={() => setDraggedItem({ type: 'available', id: stop.id })}
onDragEnd={() => setDraggedItem(null)}
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>
</article>
))}
</div>
<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 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>
-19
View File
@@ -22,19 +22,6 @@ export default function Login({ onLogin }) {
} 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>
@@ -50,12 +37,6 @@ export default function Login({ onLogin }) {
<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>
)
}
+82
View File
@@ -116,6 +116,11 @@ select {
flex: 1;
}
input[readonly] {
background: #f8fafc;
color: #64748b;
}
button {
background: #111827;
color: white;
@@ -163,6 +168,83 @@ button.secondary {
margin-bottom: 6px;
}
.route-stops-board {
display: grid;
gap: 10px;
margin: 12px 0 18px;
padding: 8px;
border-radius: 18px;
border: 1px dashed rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.45);
}
.route-stops-board.drop-ready {
border-color: rgba(79, 70, 229, 0.5);
background: rgba(238, 242, 255, 0.9);
}
.route-stop-item {
display: flex;
align-items: center;
gap: 12px;
background: rgba(248, 250, 252, 0.96);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
cursor: grab;
}
.route-stop-item.dragging {
opacity: 0.72;
transform: scale(0.995);
}
.route-stop-handle {
font-size: 1.2rem;
line-height: 1;
color: #64748b;
user-select: none;
}
.route-stop-content {
flex: 1;
}
.route-stop-content strong {
display: block;
margin-bottom: 4px;
}
.route-stop-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.route-stop-actions button {
padding: 8px 10px;
min-width: 42px;
}
.available-stops-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin: 12px 0 18px;
}
.available-stop {
cursor: grab;
user-select: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.available-stop:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.muted {
color: #64748b;
font-size: 0.95rem;