From d8caf51fafec098d54d4f506eb229a7a16c3533f Mon Sep 17 00:00:00 2001 From: Sven laptop Date: Wed, 6 May 2026 12:57:53 +0200 Subject: [PATCH] admin UI UX update Co-authored-by: Copilot --- GETTING_STARTED.md | 15 +- admin_ui/README.md | 16 +- admin_ui/src/components/BusLinesManager.jsx | 25 ++- admin_ui/src/components/LinkStop.jsx | 223 +++++++++++++++++--- admin_ui/src/components/Login.jsx | 19 -- admin_ui/src/styles.css | 82 +++++++ docker-compose.yml | 4 +- transit_api/index.js | 118 +++++++++++ 8 files changed, 435 insertions(+), 67 deletions(-) diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index f760091..49f12e1 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -37,7 +37,7 @@ ADMIN_USER_CODES=42 http://localhost:5174 ``` -Click "Get Dev Token" to login (development only). +Log in with your real user code and password. ## 📱 Admin UI Features @@ -86,17 +86,14 @@ Click "Get Dev Token" to login (development only). ./scripts/deploy.sh clean ``` -## 🔐 Authentication +### Authentication -### Development (Local Testing) -- Click "Get Dev Token" button in login screen -- Uses hardcoded user ID 42 for testing +Use the real login flow with user code and password. ### Production -- Implement real authentication system -- Update auth_api with your authentication provider -- Remove dev token button from code -- Configure proper admin user codes in ADMIN_USER_CODES +- Keep `DEV_BYPASS=0` and `DEV_MOCK=0` +- Configure proper admin user codes in `ADMIN_USER_CODES` +- Ensure JWTs include the `userCode` claim ## 📝 Configuration Files diff --git a/admin_ui/README.md b/admin_ui/README.md index 2f46d97..1fe3afc 100644 --- a/admin_ui/README.md +++ b/admin_ui/README.md @@ -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 ` diff --git a/admin_ui/src/components/BusLinesManager.jsx b/admin_ui/src/components/BusLinesManager.jsx index 817933c..0751c26 100644 --- a/admin_ui/src/components/BusLinesManager.jsx +++ b/admin_ui/src/components/BusLinesManager.jsx @@ -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 }) {
setLineNumber(e.target.value)} /> - setStartStop(e.target.value)} /> - setEndStop(e.target.value)} /> + +
setEstimatedDurationMinutes(e.target.value)} /> diff --git a/admin_ui/src/components/LinkStop.jsx b/admin_ui/src/components/LinkStop.jsx index c2b80d3..0d93fdb 100644 --- a/admin_ui/src/components/LinkStop.jsx +++ b/admin_ui/src/components/LinkStop.jsx @@ -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 (
@@ -61,39 +152,117 @@ export default function LinkStop({ token, user }) {

Stops per lijn

Koppel stops aan lijnen en bekijk de volgorde.

- +
- { - setBusLineId(e.target.value) - }} - /> - + onChange={e => setBusLineId(e.target.value)} + > + + {busLines.map(line => ( + + ))} + +
- {busLines.length > 0 && ( -
- Known lines: {busLines.map(line => `#${line.id} (lijn ${line.lineNumber})`).join(', ')} -
- )} +
+ Drag a stop onto the route board to append it to the line. Use the arrows to nudge the order. +
-
    - {stops.map(s => ( -
  • {s.order}. {s.name} ({s.latitude}, {s.longitude})
  • +
    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) => ( +
    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) + }} + > + +
    + {s.order}. {s.name} +
    {s.latitude}, {s.longitude}
    + {s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? ( +
    ETA {s.estimatedArrivalMinutes} min
    + ) : null} +
    +
    + + +
    +
    ))} -
+ + +

Beschikbare haltes

+
+ {availableStops.map(stop => ( +
setDraggedItem({ type: 'available', id: stop.id })} + onDragEnd={() => setDraggedItem(null)} + onClick={() => { + if (busLineId) { + setStopId(String(stop.id)) + appendDraggedStop(stop) + } + }} + role="button" + tabIndex={0} + > + #{stop.id} {stop.name} +
{stop.latitude}, {stop.longitude}
+
Sleep of klik om toe te voegen
+
+ ))} +

Link a Stop to Line

{isAdmin ? ( - - setStopId(e.target.value)} /> - setOrder(e.target.value)} /> - setEta(e.target.value)} /> - + +
+ + + setEta(e.target.value)} /> +
+
+ +
) : (
You must be admin to link stops.
diff --git a/admin_ui/src/components/Login.jsx b/admin_ui/src/components/Login.jsx index 4f70fec..4d295af 100644 --- a/admin_ui/src/components/Login.jsx +++ b/admin_ui/src/components/Login.jsx @@ -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 (

Admin Login

@@ -50,12 +37,6 @@ export default function Login({ onLogin }) { {error &&
{error}
} -

Be sure `ADMIN_USER_CODES` contains your `userCode` for admin actions.

-
-
- -
Uses `/dev/token` on the auth API for quick local testing.
-
) } diff --git a/admin_ui/src/styles.css b/admin_ui/src/styles.css index 33d6750..4b4e19a 100644 --- a/admin_ui/src/styles.css +++ b/admin_ui/src/styles.css @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 165b60e..71d68d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,8 +54,8 @@ services: ports: - "5174:80" environment: - VITE_AUTH_API: http://192.168.2.66:5000 - VITE_TRANSIT_API: http://192.168.2.66:5001 + VITE_AUTH_API: /api/auth + VITE_TRANSIT_API: /api/transit depends_on: - auth_api - transit_api diff --git a/transit_api/index.js b/transit_api/index.js index 50f8294..b25ab27 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -564,6 +564,124 @@ app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, a } }); +app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddleware, async (req, res) => { + const client = await db.pool.connect(); + try { + const { busLineId } = req.params; + const { routeStopIds } = req.body || {}; + + if (!Array.isArray(routeStopIds) || routeStopIds.length === 0) { + return res.status(400).json({ error: 'routeStopIds array required' }); + } + + const normalizedIds = routeStopIds.map(id => Number(id)).filter(id => Number.isFinite(id)); + if (normalizedIds.length !== routeStopIds.length) { + return res.status(400).json({ error: 'routeStopIds must contain only numeric ids' }); + } + + if (DEV_MOCK) { + const busLineStops = _mock.routeStops + .filter(rs => String(rs.busLineId) === String(busLineId)) + .sort((a, b) => a.stopOrder - b.stopOrder); + + const idSet = new Set(busLineStops.map(rs => Number(rs.id))); + const sameCount = busLineStops.length === normalizedIds.length; + const sameIds = sameCount && normalizedIds.every(id => idSet.has(id)); + if (!sameIds) { + return res.status(400).json({ error: 'routeStopIds must match current bus line stops' }); + } + + const updated = normalizedIds.map((routeStopId, index) => { + const stop = _mock.routeStops.find(rs => Number(rs.id) === routeStopId && String(rs.busLineId) === String(busLineId)); + if (!stop) return null; + stop.stopOrder = index + 1; + stop.updatedAt = new Date().toISOString(); + return stop; + }); + + if (updated.some(item => !item)) { + return res.status(400).json({ error: 'routeStopIds must match current bus line stops' }); + } + + _mock.routeStops = _mock.routeStops.map(routeStop => { + const reordered = updated.find(item => item.id === routeStop.id); + return reordered || routeStop; + }); + + return res.json({ + routeStops: updated, + }); + } + + const current = await client.query( + `SELECT id FROM transit.route_stops WHERE bus_line_id = $1 ORDER BY stop_order ASC`, + [busLineId] + ); + + const currentIds = current.rows.map(row => Number(row.id)); + const sameCount = currentIds.length === normalizedIds.length; + const sameIds = sameCount && normalizedIds.every(id => currentIds.includes(id)); + + if (!sameIds) { + return res.status(400).json({ error: 'routeStopIds must match current bus line stops' }); + } + + await client.query('BEGIN'); + + for (let index = 0; index < normalizedIds.length; index += 1) { + await client.query( + `UPDATE transit.route_stops + SET stop_order = $1, updated_at = NOW() + WHERE id = $2 AND bus_line_id = $3`, + [-(index + 1), normalizedIds[index], busLineId] + ); + } + + for (let index = 0; index < normalizedIds.length; index += 1) { + await client.query( + `UPDATE transit.route_stops + SET stop_order = $1, updated_at = NOW() + WHERE id = $2 AND bus_line_id = $3`, + [index + 1, normalizedIds[index], busLineId] + ); + } + + const result = await client.query( + `SELECT + rs.id, + rs.stop_order, + s.name as stop_name, + s.latitude, + s.longitude, + rs.estimated_arrival_minutes + FROM transit.route_stops rs + JOIN transit.stops s ON s.id = rs.stop_id + WHERE rs.bus_line_id = $1 + ORDER BY rs.stop_order ASC`, + [busLineId] + ); + + await client.query('COMMIT'); + + res.json({ + routeStops: result.rows.map(row => ({ + id: row.id, + order: row.stop_order, + name: row.stop_name, + latitude: parseFloat(row.latitude), + longitude: parseFloat(row.longitude), + estimatedArrivalMinutes: row.estimated_arrival_minutes, + })), + }); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } finally { + client.release(); + } +}); + // ============================================ // NAVIGATION - Save calculated route // ============================================