+6
-9
@@ -37,7 +37,7 @@ ADMIN_USER_CODES=42
|
|||||||
http://localhost:5174
|
http://localhost:5174
|
||||||
```
|
```
|
||||||
|
|
||||||
Click "Get Dev Token" to login (development only).
|
Log in with your real user code and password.
|
||||||
|
|
||||||
## 📱 Admin UI Features
|
## 📱 Admin UI Features
|
||||||
|
|
||||||
@@ -86,17 +86,14 @@ Click "Get Dev Token" to login (development only).
|
|||||||
./scripts/deploy.sh clean
|
./scripts/deploy.sh clean
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 Authentication
|
### Authentication
|
||||||
|
|
||||||
### Development (Local Testing)
|
Use the real login flow with user code and password.
|
||||||
- Click "Get Dev Token" button in login screen
|
|
||||||
- Uses hardcoded user ID 42 for testing
|
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
- Implement real authentication system
|
- Keep `DEV_BYPASS=0` and `DEV_MOCK=0`
|
||||||
- Update auth_api with your authentication provider
|
- Configure proper admin user codes in `ADMIN_USER_CODES`
|
||||||
- Remove dev token button from code
|
- Ensure JWTs include the `userCode` claim
|
||||||
- Configure proper admin user codes in ADMIN_USER_CODES
|
|
||||||
|
|
||||||
## 📝 Configuration Files
|
## 📝 Configuration Files
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -61,8 +61,8 @@ docker build -t xtransit/admin_ui:latest .
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 5174:80 \
|
docker run -p 5174:80 \
|
||||||
-e VITE_AUTH_API=http://localhost:5000 \
|
-e VITE_AUTH_API=/api/auth \
|
||||||
-e VITE_TRANSIT_API=http://localhost:5001 \
|
-e VITE_TRANSIT_API=/api/transit \
|
||||||
xtransit/admin_ui:latest
|
xtransit/admin_ui:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ http://localhost:5174
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `VITE_AUTH_API` | Auth API URL (internal: http://auth_api:5000) | http://localhost:5000 |
|
| `VITE_AUTH_API` | Auth API URL (same-origin path recommended in Docker) | http://localhost:5000 |
|
||||||
| `VITE_TRANSIT_API` | Transit API URL (internal: http://transit_api:5001) | http://localhost:5001 |
|
| `VITE_TRANSIT_API` | Transit API URL (same-origin path recommended in Docker) | http://localhost:5001 |
|
||||||
|
|
||||||
### Runtime Configuration
|
### Runtime Configuration
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ When running in Docker, environment variables are injected at runtime via `docke
|
|||||||
### API Integration
|
### API Integration
|
||||||
|
|
||||||
The app communicates with:
|
The app communicates with:
|
||||||
- **Auth API** (/login, /me, /dev/token)
|
- **Auth API** (/login, /me)
|
||||||
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
|
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
|
||||||
|
|
||||||
All requests include JWT Bearer token for authentication.
|
All requests include JWT Bearer token for authentication.
|
||||||
@@ -122,9 +122,9 @@ All requests include JWT Bearer token for authentication.
|
|||||||
|
|
||||||
### Login Flow
|
### Login Flow
|
||||||
|
|
||||||
1. User clicks "Get Dev Token" button (development only)
|
1. User enters user code and password
|
||||||
2. Client calls POST `/dev/token`
|
2. Client calls POST `/login`
|
||||||
3. Receives JWT token with userId and userCode
|
3. Receives JWT token with `userId` and `userCode`
|
||||||
4. JWT is stored in localStorage
|
4. JWT is stored in localStorage
|
||||||
5. All API requests include `Authorization: Bearer <token>`
|
5. All API requests include `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { transitClient } from '../api'
|
|||||||
export default function BusLinesManager({ token, user }) {
|
export default function BusLinesManager({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
const [busLines, setBusLines] = useState([])
|
const [busLines, setBusLines] = useState([])
|
||||||
|
const [stops, setStops] = useState([])
|
||||||
const [lineNumber, setLineNumber] = useState('')
|
const [lineNumber, setLineNumber] = useState('')
|
||||||
const [startStop, setStartStop] = useState('')
|
const [startStop, setStartStop] = useState('')
|
||||||
const [endStop, setEndStop] = 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(() => {
|
useEffect(() => {
|
||||||
loadBusLines()
|
loadBusLines()
|
||||||
|
loadStops()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const createBusLine = async (e) => {
|
const createBusLine = async (e) => {
|
||||||
@@ -73,8 +84,18 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
<form onSubmit={createBusLine} className="stack-form">
|
<form onSubmit={createBusLine} className="stack-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
|
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
|
||||||
<input placeholder="Start stop" value={startStop} onChange={e => setStartStop(e.target.value)} />
|
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
|
||||||
<input placeholder="End stop" value={endStop} onChange={e => setEndStop(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>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from '../api'
|
||||||
|
|
||||||
export default function LinkStop({ token, user }) {
|
export default function LinkStop({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
const [busLines, setBusLines] = useState([])
|
const [busLines, setBusLines] = useState([])
|
||||||
|
const [availableStops, setAvailableStops] = useState([])
|
||||||
const [busLineId, setBusLineId] = useState('')
|
const [busLineId, setBusLineId] = useState('')
|
||||||
const [stopId, setStopId] = useState('')
|
const [stopId, setStopId] = useState('')
|
||||||
const [order, setOrder] = useState('')
|
|
||||||
const [eta, setEta] = useState('')
|
const [eta, setEta] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [stops, setStops] = useState([])
|
const [stops, setStops] = useState([])
|
||||||
|
const [draggedItem, setDraggedItem] = useState(null)
|
||||||
|
|
||||||
const isAdmin = Boolean(user?.userCode)
|
const isAdmin = Boolean(user?.userCode)
|
||||||
|
|
||||||
|
const nextOrder = useMemo(() => stops.length + 1, [stops])
|
||||||
|
|
||||||
const loadBusLines = async () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/bus-lines')
|
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(() => {
|
useEffect(() => {
|
||||||
loadBusLines()
|
loadBusLines()
|
||||||
|
loadAvailableStops()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStopsForLine()
|
||||||
|
}, [busLineId])
|
||||||
|
|
||||||
const link = async (e) => {
|
const link = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
setMessage(null)
|
||||||
try {
|
try {
|
||||||
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
||||||
stopId: parseInt(stopId,10),
|
stopId: parseInt(stopId, 10),
|
||||||
stopOrder: parseInt(order,10),
|
stopOrder: nextOrder,
|
||||||
estimatedArrivalMinutes: eta ? parseInt(eta,10) : null
|
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
|
||||||
})
|
})
|
||||||
setMessage('Linked: ' + res.data.routeStop.id)
|
setMessage('Linked: ' + res.data.routeStop.id)
|
||||||
|
setStopId('')
|
||||||
await loadStopsForLine()
|
await loadStopsForLine()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 403) setMessage('Forbidden: not admin')
|
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 (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
@@ -61,39 +152,117 @@ export default function LinkStop({ token, user }) {
|
|||||||
<h2>Stops per lijn</h2>
|
<h2>Stops per lijn</h2>
|
||||||
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
|
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary" onClick={() => loadStopsForLine()}>Refresh</button>
|
<button className="secondary" onClick={refreshBoard}>Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input
|
<select
|
||||||
placeholder="Bus line ID"
|
|
||||||
value={busLineId}
|
value={busLineId}
|
||||||
onChange={e => {
|
onChange={e => setBusLineId(e.target.value)}
|
||||||
setBusLineId(e.target.value)
|
>
|
||||||
}}
|
<option value="">Choose line</option>
|
||||||
/>
|
{busLines.map(line => (
|
||||||
<button type="button" onClick={() => loadStopsForLine()}>Load Stops</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{busLines.length > 0 && (
|
<div className="muted" style={{ marginBottom: 10 }}>
|
||||||
<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.
|
||||||
Known lines: {busLines.map(line => `#${line.id} (lijn ${line.lineNumber})`).join(', ')}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul>
|
<div
|
||||||
{stops.map(s => (
|
className={`route-stops-board ${draggedItem?.type === 'available' ? 'drop-ready' : ''}`}
|
||||||
<li key={s.id}>{s.order}. {s.name} ({s.latitude}, {s.longitude})</li>
|
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>
|
<h3>Link a Stop to Line</h3>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<form onSubmit={link} className="form-row">
|
<form onSubmit={link} className="stack-form">
|
||||||
<input placeholder="Stop ID" value={stopId} onChange={e=>setStopId(e.target.value)} />
|
<div className="form-row">
|
||||||
<input placeholder="Stop Order" value={order} onChange={e=>setOrder(e.target.value)} />
|
<select value={stopId} onChange={e=>setStopId(e.target.value)}>
|
||||||
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
|
<option value="">Select stop</option>
|
||||||
<button type="submit">Link Stop</button>
|
{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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div>You must be admin to link stops.</div>
|
<div>You must be admin to link stops.</div>
|
||||||
|
|||||||
@@ -22,19 +22,6 @@ export default function Login({ onLogin }) {
|
|||||||
} finally { setLoading(false) }
|
} 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 (
|
return (
|
||||||
<div className="login">
|
<div className="login">
|
||||||
<h2>Admin Login</h2>
|
<h2>Admin Login</h2>
|
||||||
@@ -50,12 +37,6 @@ export default function Login({ onLogin }) {
|
|||||||
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
|
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
</form>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ select {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -163,6 +168,83 @@ button.secondary {
|
|||||||
margin-bottom: 6px;
|
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 {
|
.muted {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
+2
-2
@@ -54,8 +54,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5174:80"
|
- "5174:80"
|
||||||
environment:
|
environment:
|
||||||
VITE_AUTH_API: http://192.168.2.66:5000
|
VITE_AUTH_API: /api/auth
|
||||||
VITE_TRANSIT_API: http://192.168.2.66:5001
|
VITE_TRANSIT_API: /api/transit
|
||||||
depends_on:
|
depends_on:
|
||||||
- auth_api
|
- auth_api
|
||||||
- transit_api
|
- transit_api
|
||||||
|
|||||||
@@ -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
|
// NAVIGATION - Save calculated route
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user