diff --git a/admin_ui/index.html b/admin_ui/index.html index 45615e2..ca9d1b8 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -1,13 +1,20 @@ - + - Transit Admin + + + + + Transit Beheercentrum
- + \ No newline at end of file diff --git a/admin_ui/src/App.jsx b/admin_ui/src/App.jsx index a6609eb..61adf5b 100644 --- a/admin_ui/src/App.jsx +++ b/admin_ui/src/App.jsx @@ -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 { setToken(t); setUser(u); }} /> + if (!token) return { 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 ( -
-
-
-

Transit operations

-

Admin control center

+
+
-
-
-

Fleet, lijnen en stops

-

Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.

+ + + +
+
+
{initials}
+
+
{user?.name || user?.userCode || 'Gebruiker'}
+
{user?.isAdmin ? 'Beheerder' : 'Medewerker'}
+
+ +
+ + +
+
+
+

{active.eyebrow}

+

{active.title}

+

{active.desc}

+
+
+ +
+ {section === 'overview' && } + {section === 'stops' && } + {section === 'lines' && } + {section === 'routes' && } + {section === 'fleet' && } + {section === 'schedules' && } + {section === 'logs' && } +
-
- - - - - - -
) -} +} \ No newline at end of file diff --git a/admin_ui/src/components/BusLinesManager.jsx b/admin_ui/src/components/BusLinesManager.jsx index 413ddc3..dd7a8f8 100644 --- a/admin_ui/src/components/BusLinesManager.jsx +++ b/admin_ui/src/components/BusLinesManager.jsx @@ -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 }) {

Buslijnen

-

Maak en bewerk buslijnen.

+

Definieer lijnen tussen start- en eindhalte.

- +
{busLines.map(line => (
- #{line.id} Lijn {line.lineNumber} -
{line.startStop} → {line.endStop}
-
{line.estimatedDurationMinutes} min
- {line.description ?
{line.description}
: null} + Lijn {line.lineNumber} +
#{line.id}
+
{line.startStop} → {line.endStop}
+
{line.estimatedDurationMinutes} min
+ {line.description ?
{line.description}
: null} {isAdmin && ( - <> - - - +
+ + +
)}
))} @@ -127,120 +133,66 @@ export default function BusLinesManager({ token, user }) { {isAdmin ? (
- setLineNumber(e.target.value)} /> + setLineNumber(e.target.value)} />
- setEstimatedDurationMinutes(e.target.value)} /> - setDescription(e.target.value)} /> - + setEstimatedDurationMinutes(e.target.value)} /> + setDescription(e.target.value)} /> +
- ) :
You must be admin to create bus lines.
} + ) :
Alleen beheerders kunnen buslijnen aanmaken.
} - {message &&
{message}
} + {message &&
{message}
} {editingLine && ( -
-
-

Edit Bus Line

-
{ - 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"> -
- setEditingLine({...editingLine, lineNumber: e.target.value})} /> - - -
-
- setEditingLine({...editingLine, estimatedDurationMinutes: e.target.value})} /> - setEditingLine({...editingLine, description: e.target.value})} /> -
-
- - -
-
+ setEditingLine(null)} + footer={ + <> + + + + }> +
+ setEditingLine({ ...editingLine, lineNumber: e.target.value })} />
-
+
+ + +
+
+ setEditingLine({ ...editingLine, estimatedDurationMinutes: e.target.value })} /> + setEditingLine({ ...editingLine, description: e.target.value })} /> +
+ )} {deleteConfirm && ( -
-
-

Delete Bus Line?

-

Lijn {deleteConfirm.lineNumber} ({deleteConfirm.startStop} → {deleteConfirm.endStop})

-

This will also delete all stops on this line. Cannot be undone.

-
- - -
-
-
+ setDeleteConfirm(null)} + footer={ + <> + + + + }> +

Weet je zeker dat je Lijn {deleteConfirm.lineNumber} ({deleteConfirm.startStop} → {deleteConfirm.endStop}) wilt verwijderen?

+

Hiermee worden ook alle gekoppelde stops op deze lijn verwijderd. Dit kan niet worden ongedaan gemaakt.

+
)} ) -} - -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)', -} +} \ No newline at end of file diff --git a/admin_ui/src/components/ErrorLogViewer.jsx b/admin_ui/src/components/ErrorLogViewer.jsx index 9c8c372..7edd75a 100644 --- a/admin_ui/src/components/ErrorLogViewer.jsx +++ b/admin_ui/src/components/ErrorLogViewer.jsx @@ -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,41 +55,55 @@ export default function ErrorLogViewer({ token }) {

Laatste foutmeldingen uit de centrale logging service.

- setService(e.target.value)} - /> - setPartofservice(e.target.value)} - /> + setService(e.target.value)} /> + setPartofservice(e.target.value)} /> + {(service || partofservice) && ( + + )}
- {message &&
{message}
} + {message &&
{message}
} -
- {logs.map((log) => ( -
- {log.service} -
{formatDate(log.date)}
-
- {log.partofservice || "—"} -
-
{log.error}
-
- ))} -
- - {!logs.length && !message && ( -
Nog geen foutmeldingen gevonden.
+ {logs.length > 0 ? ( +
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + ))} + +
DatumServiceLocatieFout
{formatDate(log.date)} + {log.service} + {log.partofservice || "—"} + {log.error} +
+
+ ) : ( +
+ +

Nog geen foutmeldingen gevonden.

+
)} ); -} +} \ No newline at end of file diff --git a/admin_ui/src/components/FixedScheduleEditor.jsx b/admin_ui/src/components/FixedScheduleEditor.jsx index 808896e..828e8ec 100644 --- a/admin_ui/src/components/FixedScheduleEditor.jsx +++ b/admin_ui/src/components/FixedScheduleEditor.jsx @@ -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 diff --git a/admin_ui/src/components/FleetManager.jsx b/admin_ui/src/components/FleetManager.jsx index e1096b7..b005d46 100644 --- a/admin_ui/src/components/FleetManager.jsx +++ b/admin_ui/src/components/FleetManager.jsx @@ -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 }) {
-

Fleet

-

Beheer voertuigen voor de vloot.

+

Vloot

+

Beheer voertuigen die deel uitmaken van de vloot.

- +
{buses.map(bus => (
- #{bus.id} {bus.name} -
Seats: {bus.seatCapacity}
-
Plate: {bus.licensePlate}
+ {bus.name} +
#{bus.id}
+
{bus.seatCapacity} zitplaatsen
+
{bus.licensePlate}
{isAdmin && ( - <> - - - +
+ + +
)}
))} @@ -101,73 +111,47 @@ export default function FleetManager({ token, user }) { {isAdmin && canCreateBus ? (
- setName(e.target.value)} /> - setSeatCapacity(e.target.value)} /> - setLicensePlate(e.target.value)} /> - + setName(e.target.value)} /> + setSeatCapacity(e.target.value)} /> + setLicensePlate(e.target.value)} /> +
) : ( -
Fleet create is read-only on this database user.
+
Vloot is read-only voor deze database-gebruiker.
)} - {message &&
{message}
} + {message &&
{message}
} {editingBus && ( -
-
-

Edit Bus

-
{ - 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"> -
- setEditingBus({...editingBus, name: e.target.value})} /> - setEditingBus({...editingBus, seatCapacity: e.target.value})} /> - setEditingBus({...editingBus, licensePlate: e.target.value})} /> -
-
- - -
-
+ setEditingBus(null)} + footer={ + <> + + + + }> +
+ setEditingBus({ ...editingBus, name: e.target.value })} />
-
+
+ setEditingBus({ ...editingBus, seatCapacity: e.target.value })} /> + setEditingBus({ ...editingBus, licensePlate: e.target.value })} /> +
+ )} {deleteConfirm && ( -
-
-

Delete Bus?

-

{deleteConfirm.name}

-
- - -
-
-
+ setDeleteConfirm(null)} + footer={ + <> + + + + }> +

Weet je zeker dat je {deleteConfirm.name} ({deleteConfirm.licensePlate}) wilt verwijderen?

+
)}
) -} +} \ No newline at end of file diff --git a/admin_ui/src/components/LinkStop.jsx b/admin_ui/src/components/LinkStop.jsx index 1cf78ea..dde25f5 100644 --- a/admin_ui/src/components/LinkStop.jsx +++ b/admin_ui/src/components/LinkStop.jsx @@ -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 }) {

Stops per lijn

-

Koppel stops aan lijnen en bekijk de volgorde.

+

Koppel haltes aan een lijn en bepaal de rijvolgorde.

- +
- setBusLineId(e.target.value)}> + {busLines.map(line => ( ))} - +
- 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.
+ {stops.length === 0 && ( +

+ Nog geen haltes gekoppeld — sleep een halte hierheen. +

+ )} {stops.map((s, index) => (
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 }) { }} > +
{s.order}
- {s.order}. {s.name} + {s.name}
{s.latitude}, {s.longitude}
{s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? (
ETA {s.estimatedArrivalMinutes} min
) : null}
- - + + {isAdmin && ( - + )}
))}
-

Beschikbare haltes

+

Beschikbare haltes

{availableStops.map(stop => (
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} > #{stop.id} {stop.name}
{stop.latitude}, {stop.longitude}
-
Sleep of klik om toe te voegen
+
Sleep of klik om toe te voegen
))}
-

Link a Stop to Line

- {isAdmin ? ( -
-
- - - setEta(e.target.value)} /> -
-
- -
-
- ) : ( -
You must be admin to link stops.
- )} - {message &&
{message}
} +
+

Halte koppelen aan lijn

+ {isAdmin ? ( +
+
+ + + setEta(e.target.value)} /> +
+
+ +
+
+ ) : ( +
Alleen beheerders kunnen haltes koppelen.
+ )} +
+ + {message &&
{message}
} {deleteConfirm && ( -
-
-

Remove Stop from Line?

-

{deleteConfirm.name}

-

This will remove the stop from the route and re-order the remaining stops.

-
- - -
-
-
+ setDeleteConfirm(null)} + footer={ + <> + + + + }> +

Weet je zeker dat je {deleteConfirm.name} uit deze lijn wilt verwijderen?

+

De halte wordt uit de route gehaald en de resterende stops worden opnieuw geordend.

+
)} ) -} - -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)', -} +} \ No newline at end of file diff --git a/admin_ui/src/components/Login.jsx b/admin_ui/src/components/Login.jsx index 4d295af..c5b713c 100644 --- a/admin_ui/src/components/Login.jsx +++ b/admin_ui/src/components/Login.jsx @@ -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 ( -
-

Admin Login

-
- - - - {error &&
{error}
} -
+
+
+
+
+
+

Transit Beheer

+

Operationeel dashboard voor netwerkbeheer

+
+
+
+ + + {error &&
{error}
} + +
+
Alleen beheerders hebben toegang
+
) -} +} \ No newline at end of file diff --git a/admin_ui/src/components/Modal.jsx b/admin_ui/src/components/Modal.jsx new file mode 100644 index 0000000..de264f5 --- /dev/null +++ b/admin_ui/src/components/Modal.jsx @@ -0,0 +1,16 @@ +import React from 'react' + +export default function Modal({ title, onClose, children, footer, wide }) { + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true"> +
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ) +} \ No newline at end of file diff --git a/admin_ui/src/components/Overview.jsx b/admin_ui/src/components/Overview.jsx new file mode 100644 index 0000000..e04bc82 --- /dev/null +++ b/admin_ui/src/components/Overview.jsx @@ -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 ( + <> +
+ {statCards.map((card) => { + const Icon = card.icon + return ( +
+
+
+
{card.value === null ? '…' : card.value}
+
{card.label}
+
+
+ ) + })} +
+ + + + ) +} \ No newline at end of file diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx index b50c7ba..5f7a0d8 100644 --- a/admin_ui/src/components/ScheduleMaker.jsx +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -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 ( -
- - {message &&
{message}
} +
+
+ + +
+ + {tab === "weekly" ? ( + + ) : ( + + )} + + {tab === "weekly" && message &&
{message}
}
); -} +} \ No newline at end of file diff --git a/admin_ui/src/components/StopsManager.jsx b/admin_ui/src/components/StopsManager.jsx index e8d35b8..501701c 100644 --- a/admin_ui/src/components/StopsManager.jsx +++ b/admin_ui/src/components/StopsManager.jsx @@ -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 }) {
-

Stops

-

Maak herbruikbare haltes aan.

+

Haltes

+

Maak herbruikbare haltes aan en bewerk hun coördinaten.

- +
+
{stops.map(stop => (
- #{stop.id} {stop.name} -
{stop.latitude}, {stop.longitude}
+ Halte #{stop.id} · {stop.name} +
# {stop.latitude}, {stop.longitude}
{isAdmin && ( - - )} - {isAdmin && ( - +
+ + +
)}
))}
+ {isAdmin ? (
- setName(e.target.value)} /> - setLat(e.target.value)} /> - setLon(e.target.value)} /> - + setName(e.target.value)} /> + setLat(e.target.value)} /> + setLon(e.target.value)} /> +
) : ( -
You must be admin to create stops.
+
Alleen beheerders kunnen haltes aanmaken.
)} - - {message &&
{message}
} + + {message &&
{message}
} {deleteConfirm && ( -
-
-

Delete Stop?

-

{deleteConfirm.name}

-

This cannot be undone.

-
- - -
-
-
+ setDeleteConfirm(null)} + footer={ + <> + + + + }> +

Weet je zeker dat je {deleteConfirm.name} wilt verwijderen?

+

Dit kan niet ongedaan worden gemaakt.

+
)} {editingStop && ( -
-
-

Edit Stop

-
{ - 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"> -
- setEditingStop({...editingStop, name: e.target.value})} /> - setEditingStop({...editingStop, latitude: e.target.value})} /> - setEditingStop({...editingStop, longitude: e.target.value})} /> -
-
- - -
-
+ setEditingStop(null)} + footer={ + <> + + + + }> +
+ setEditingStop({ ...editingStop, name: e.target.value })} />
-
+
+ setEditingStop({ ...editingStop, latitude: e.target.value })} /> + setEditingStop({ ...editingStop, longitude: e.target.value })} /> +
+ )}
) -} - -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)', -} +} \ No newline at end of file diff --git a/admin_ui/src/components/TimetableDisplay.jsx b/admin_ui/src/components/TimetableDisplay.jsx index fb0684c..b77a8de 100644 --- a/admin_ui/src/components/TimetableDisplay.jsx +++ b/admin_ui/src/components/TimetableDisplay.jsx @@ -84,19 +84,22 @@ const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDrive

Dagelijks Overzicht

Alle geplande diensten voor de geselecteerde dag

-
+
setOverviewDate(e.target.value)} + className="no-grow" + style={{ minWidth: 150 }} /> - setLineId(event.target.value)} - > - + - setCategory(event.target.value)}> {DAY_GROUPS.map((group) => ( - + ))}
-
+
setNewStartTime(event.target.value)} - aria-label="New departure time" + aria-label="Nieuwe vertrektijd" + className="no-grow" + style={{ minWidth: 130 }} /> - -
+
{startTimes.map((time) => ( - + {time} - + ))}
- setDriverId(event.target.value)}> + {drivers.map((driver) => ( - + ))} -
- {message &&
{message}
} + {message &&
{message}
} -
+
{DAY_GROUPS.map((group) => ( -
-

{group.label}

+
+

{group.label}

{(groupedSchedules[group.value] || []).length === 0 ? ( -

No schedules configured.

+

Nog geen schema geconfigureerd.

) : ( -
+
{groupedSchedules[group.value].map((schedule) => (
- Line {schedule.lineNumber} + Lijn {schedule.lineNumber} - {String(schedule.startTime).slice(0, 5)} -{" "} - {String(schedule.endTime).slice(0, 5)} + {String(schedule.startTime).slice(0, 5)} – {String(schedule.endTime).slice(0, 5)}
- {dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "} - {schedule.endStop} +
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} → {schedule.endStop}
))} @@ -284,4 +211,4 @@ export default function WeeklyScheduleEditor({
); -} +} \ No newline at end of file diff --git a/admin_ui/src/components/icons.jsx b/admin_ui/src/components/icons.jsx new file mode 100644 index 0000000..a287050 --- /dev/null +++ b/admin_ui/src/components/icons.jsx @@ -0,0 +1,97 @@ +import React from 'react' + +const Svg = ({ size = 18, children, ...props }) => ( + +) + +export const IconGrid = (p) => ( + + + + + + +) + +export const IconMapPin = (p) => ( + + + + +) + +export const IconRoute = (p) => ( + + + + + +) + +export const IconLink = (p) => ( + + + + +) + +export const IconBus = (p) => ( + + + + + + + +) + +export const IconCalendar = (p) => ( + + + + +) + +export const IconAlert = (p) => ( + + + + +) + +export const IconLogout = (p) => ( + + + + +) + +export const IconBusFront = (p) => ( + + + + + + + +) + +export const IconClock = (p) => ( + + + + +) \ No newline at end of file diff --git a/admin_ui/src/styles.css b/admin_ui/src/styles.css index 593a786..7da3626 100644 --- a/admin_ui/src/styles.css +++ b/admin_ui/src/styles.css @@ -1,246 +1,932 @@ +/* ============================================================ + Transit Admin — Design System + ============================================================ */ + +:root { + --bg: #eef1f6; + --surface: #ffffff; + --surface-2: #f8fafc; + --surface-3: #f1f5f9; + --border: #e2e8f0; + --border-strong: #cbd5e1; + --text: #0f172a; + --text-muted: #5b6b83; + --text-faint: #94a3b8; + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-soft: #eef2ff; + --primary-soft-2: #e0e7ff; + --accent: #0284c7; + --accent-soft: #e0f2fe; + --success: #059669; + --success-soft: #d1fae5; + --danger: #e11d48; + --danger-hover: #be123c; + --danger-soft: #ffe4e6; + --warning: #d97706; + --warning-soft: #fef3c7; + --sidebar-bg: #0e1526; + --sidebar-bg-2: #131c34; + --sidebar-text: #93a4c0; + --radius: 16px; + --radius-sm: 10px; + --radius-xs: 8px; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); + --shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08); + --shadow-lg: 0 20px 44px rgba(15, 23, 42, 0.16); + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + 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; + color: var(--text); + background: var(--bg); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; } -.app { - max-width: 1180px; - margin: 0 auto; - padding: 28px 18px 48px; -} +/* ============================================================ + Layout shell + ============================================================ */ -.topbar { +.app-shell { display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 20px; + min-height: 100vh; } -.topbar h1 { - margin: 4px 0 0; - font-size: 2.2rem; +.sidebar { + width: 258px; + flex-shrink: 0; + position: sticky; + top: 0; + height: 100vh; + background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-bg-2) 100%); + color: var(--sidebar-text); + display: flex; + flex-direction: column; + padding: 22px 16px 18px; } -.eyebrow { - margin: 0; - text-transform: uppercase; - letter-spacing: 0.12em; - color: #4f46e5; - font-size: 0.75rem; - font-weight: 700; -} - -.userbox { +.brand { 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); + padding: 2px 8px 22px; } -.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 { +.brand-mark { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 12px; + background: linear-gradient(135deg, #6366f1, #0ea5e9); display: grid; - gap: 18px; + place-items: center; + color: #fff; + box-shadow: 0 8px 18px rgba(99, 102, 241, 0.4); } -.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); +.brand-text .brand-name { + font-size: 1.05rem; + font-weight: 700; + color: #fff; + letter-spacing: -0.01em; + line-height: 1.15; +} + +.brand-text .brand-sub { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: rgba(147, 164, 192, 0.7); +} + +.nav-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgba(147, 164, 192, 0.55); + margin: 20px 12px 8px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + margin-bottom: 4px; + border: none; + border-radius: 12px; + background: transparent; + color: var(--sidebar-text); + font: inherit; + font-size: 0.93rem; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; +} + +.nav-item svg { + flex-shrink: 0; +} + +.nav-item:hover { + background: rgba(255, 255, 255, 0.06); + color: #e2e8f0; +} + +.nav-item:active { + transform: translateY(1px); +} + +.nav-item.active { + background: rgba(99, 102, 241, 0.18); + color: #fff; + box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.4); +} + +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 14px; +} + +.user-chip { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; +} + +.user-avatar { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #0ea5e9); + display: grid; + place-items: center; + color: #fff; + font-weight: 700; + font-size: 0.85rem; + text-transform: uppercase; +} + +.user-meta { + min-width: 0; + flex: 1; +} + +.user-meta .user-name { + color: #e2e8f0; + font-weight: 600; + font-size: 0.88rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-meta .user-role { + font-size: 0.72rem; + color: rgba(147, 164, 192, 0.75); +} + +.logout-btn { + border: none; + background: transparent; + color: var(--sidebar-text); + cursor: pointer; + padding: 8px; + border-radius: 10px; + display: grid; + place-items: center; + transition: background 0.15s ease, color 0.15s ease; +} + +.logout-btn:hover { + background: rgba(225, 29, 72, 0.16); + color: #fda4af; +} + +.main { + flex: 1; + min-width: 0; + margin: 0 auto; + max-width: 1400px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 20px 32px 14px; + background: linear-gradient(180deg, rgba(238, 241, 246, 0.95) 0%, rgba(238, 241, 246, 0.8) 100%); + backdrop-filter: blur(10px); +} + +.topbar-title .eyebrow { + margin: 0 0 4px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--primary); + font-size: 0.72rem; + font-weight: 700; +} + +.topbar-title h1 { + margin: 0; + font-size: 1.55rem; + letter-spacing: -0.02em; +} + +.topbar-title p { + margin: 4px 0 0; + color: var(--text-muted); + font-size: 0.92rem; +} + +.content { + padding: 10px 32px 48px; + display: grid; + gap: 20px; +} + +/* ============================================================ + Cards + ============================================================ */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 22px; + box-shadow: var(--shadow-sm); } .section-header { display: flex; justify-content: space-between; align-items: flex-start; - gap: 12px; - margin-bottom: 14px; + gap: 16px; + margin-bottom: 16px; } +.section-header h2, +.section-header h3 { + margin: 0 0 4px; + font-size: 1.15rem; + font-weight: 650; + letter-spacing: -0.01em; +} + +.section-header p { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.list-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin: 4px 0 18px; +} + +.mini-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 16px; + transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease; +} + +.mini-card:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.mini-card strong { + display: block; + margin-bottom: 4px; + font-size: 0.98rem; +} + +.mini-card .meta-line { + color: var(--text-muted); + font-size: 0.85rem; + margin-top: 2px; +} + +.mini-card .card-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} + +.stack { + display: grid; + gap: 20px; +} + +/* ============================================================ + Forms + ============================================================ */ + .form-row { display: flex; gap: 10px; margin: 10px 0; flex-wrap: wrap; + align-items: center; } .stack-form { - margin-top: 10px; + margin-top: 12px; + padding-top: 14px; + border-top: 1px dashed var(--border); } input, select, button { - border-radius: 12px; - border: 1px solid rgba(148, 163, 184, 0.45); - padding: 11px 12px; + border-radius: var(--radius-xs); + border: 1px solid var(--border-strong); + padding: 10px 12px; font: inherit; + font-size: 0.92rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; } input, select { - background: white; - min-width: 180px; + background: var(--surface); + min-width: 170px; flex: 1; + color: var(--text); +} + +input:focus, +select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); } input[readonly] { - background: #f8fafc; - color: #64748b; + background: var(--surface-3); + color: var(--text-muted); } +input[type="checkbox"] { + min-width: auto; + flex: none; + accent-color: var(--primary); +} + +/* Buttons */ button { - background: #111827; - color: white; + background: var(--primary); + color: #fff; cursor: pointer; - transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; + border: 1px solid transparent; + font-weight: 550; + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; } button:hover { + background: var(--primary-hover); + box-shadow: 0 8px 20px rgba(79, 70, 229, 0.22); transform: translateY(-1px); - box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16); +} + +button:active { + transform: translateY(0); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + box-shadow: none; } button.secondary { - background: white; - color: #0f172a; + background: var(--surface); + color: var(--text); + border-color: var(--border-strong); +} + +button.secondary:hover { + background: var(--surface-3); + box-shadow: var(--shadow-sm); } button.danger { - background: #dc2626; - color: white; - border-color: #991b1b; + background: var(--danger); + border-color: transparent; } button.danger:hover { - background: #b91c1c; - box-shadow: 0 10px 24px rgba(220, 38, 38, 0.2); + background: var(--danger-hover); + box-shadow: 0 8px 20px rgba(225, 29, 72, 0.24); } +button.ghost { + background: transparent; + color: var(--text-muted); + border-color: transparent; +} + +button.ghost:hover { + background: var(--surface-3); + color: var(--text); + box-shadow: none; +} + +button.sm { + padding: 7px 10px; + font-size: 0.85rem; + border-radius: 8px; +} + +button.block { + width: 100%; +} + +.icon-btn { + background: transparent; + color: var(--text-faint); + border: none; + padding: 6px; + display: grid; + place-items: center; + line-height: 1; +} + +.icon-btn:hover { + background: var(--surface-3); + color: var(--text); + box-shadow: none; +} + +/* Segmented control */ +.segmented { + display: inline-flex; + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px; + gap: 4px; +} + +.segmented button { + background: transparent; + color: var(--text-muted); + border: none; + padding: 8px 16px; + font-size: 0.9rem; + border-radius: 9px; + font-weight: 550; +} + +.segmented button:hover { + background: rgba(255, 255, 255, 0.7); + box-shadow: none; +} + +.segmented button.active { + background: var(--surface); + color: var(--primary); + box-shadow: var(--shadow-sm); +} + +/* ============================================================ + Messages / alerts + ============================================================ */ + .message { - margin-top: 10px; - color: #047857; + margin-top: 12px; + color: var(--success); font-weight: 600; + font-size: 0.92rem; + background: var(--success-soft); + border: 1px solid rgba(5, 150, 105, 0.25); + padding: 10px 14px; + border-radius: var(--radius-xs); +} + +.message.error { + color: var(--danger); + background: var(--danger-soft); + border-color: rgba(225, 29, 72, 0.28); } .error { - color: #b91c1c; + color: var(--danger); margin-top: 10px; + font-size: 0.92rem; } -.list-grid { +.muted { + color: var(--text-muted); + font-size: 0.88rem; +} + +.faint { + color: var(--text-faint); + font-size: 0.82rem; +} + +/* ============================================================ + Badges & pills + ============================================================ */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; +} + +.badge-primary { + background: var(--primary-soft); + color: var(--primary); +} + +.badge-accent { + background: var(--accent-soft); + color: var(--accent); +} + +.badge-success { + background: var(--success-soft); + color: var(--success); +} + +.badge-warning { + background: var(--warning-soft); + color: var(--warning); +} + +.badge-danger { + background: var(--danger-soft); + color: var(--danger); +} + +.badge-muted { + background: var(--surface-3); + color: var(--text-muted); +} + +.pill-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + background: var(--accent-soft); + border: 1px solid #bae6fd; + color: #075985; + font-size: 0.85rem; + font-weight: 550; +} + +.pill-chip button { + background: transparent; + border: none; + color: #075985; + cursor: pointer; + padding: 0; display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + place-items: center; + font-size: 0.95rem; + line-height: 1; +} + +.pill-chip button:hover { + background: transparent; + box-shadow: none; + color: var(--danger); +} + +/* ============================================================ + Stats (dashboard) + ============================================================ */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 20px; + display: flex; + align-items: center; + gap: 14px; + box-shadow: var(--shadow-sm); + transition: box-shadow 0.15s ease, transform 0.15s ease; +} + +.stat-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.stat-icon { + width: 44px; + height: 44px; + flex-shrink: 0; + border-radius: 12px; + display: grid; + place-items: center; +} + +.stat-icon.primary { + background: var(--primary-soft); + color: var(--primary); +} +.stat-icon.accent { + background: var(--accent-soft); + color: var(--accent); +} +.stat-icon.success { + background: var(--success-soft); + color: var(--success); +} +.stat-icon.warning { + background: var(--warning-soft); + color: var(--warning); +} +.stat-icon.danger { + background: var(--danger-soft); + color: var(--danger); +} + +.stat-card .stat-value { + font-size: 1.6rem; + font-weight: 750; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.stat-card .stat-label { + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ============================================================ + Modals + ============================================================ */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 1000; + animation: fadeIn 0.15s ease; +} + +.modal { + background: var(--surface); + border-radius: var(--radius); + width: 100%; + max-width: 460px; + max-height: 88vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); + animation: popIn 0.18s cubic-bezier(0.2, 0.9, 0.3, 1.2); +} + +.modal-wide { + max-width: 720px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 22px 0; +} + +.modal-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 650; +} + +.modal-body { + padding: 16px 22px 20px; + overflow-y: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; gap: 10px; - margin: 10px 0 16px; + padding: 14px 22px 18px; + border-top: 1px solid var(--border); } -.mini-card { - background: rgba(248, 250, 252, 0.96); - border: 1px solid rgba(148, 163, 184, 0.2); - border-radius: 16px; +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes popIn { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ============================================================ + Data tables + ============================================================ */ + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.data-table thead { + background: var(--surface-3); +} + +.data-table th { padding: 12px 14px; + text-align: left; + font-weight: 650; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + white-space: nowrap; } -.mini-card strong { - display: block; - margin-bottom: 6px; +.data-table td { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + vertical-align: top; } +.data-table tbody tr { + transition: background 0.12s ease; +} + +.data-table tbody tr:hover { + background: rgba(99, 102, 241, 0.03); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.flex-1 { flex: 1; } +.minw-0 { min-width: 0; } +.no-grow { flex: none; } + +.truncate-cell { + max-width: 340px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.error-cell { + font-family: var(--font-mono); + font-size: 0.82rem; + max-width: 460px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +/* ============================================================ + Route stop board (drag & drop) + ============================================================ */ + .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); + padding: 10px; + border-radius: var(--radius); + border: 2px dashed var(--border-strong); + background: var(--surface-3); + min-height: 60px; } .route-stops-board.drop-ready { - border-color: rgba(79, 70, 229, 0.5); - background: rgba(238, 242, 255, 0.9); + border-color: var(--primary); + background: var(--primary-soft); } .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; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 14px; - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04); + box-shadow: var(--shadow-sm); cursor: grab; + transition: box-shadow 0.15s ease; +} + +.route-stop-item:hover { + box-shadow: var(--shadow-md); } .route-stop-item.dragging { - opacity: 0.72; + opacity: 0.6; transform: scale(0.995); } .route-stop-handle { font-size: 1.2rem; line-height: 1; - color: #64748b; + color: var(--text-faint); user-select: none; + cursor: grab; } .route-stop-content { flex: 1; + min-width: 0; } .route-stop-content strong { display: block; - margin-bottom: 4px; + margin-bottom: 2px; } .route-stop-actions { display: flex; - gap: 8px; + gap: 6px; flex-shrink: 0; } .route-stop-actions button { padding: 8px 10px; - min-width: 42px; + min-width: 38px; +} + +.route-order-badge { + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: 50%; + background: var(--primary-soft); + color: var(--primary); + font-weight: 700; + font-size: 0.85rem; + display: grid; + place-items: center; } .available-stops-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; margin: 12px 0 18px; } @@ -248,54 +934,45 @@ button.danger:hover { .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); -} +/* ============================================================ + Timetable + ============================================================ */ -.muted { - color: #64748b; - font-size: 0.95rem; -} - -.login { - max-width: 540px; - margin: 60px auto; -} - -/* ============ Timetable Styles ============ */ .timetable-container { overflow-x: auto; - margin: 20px 0; - border: 1px solid rgba(148, 163, 184, 0.25); - border-radius: 12px; - background: white; + margin: 4px 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); } .timetable { width: 100%; border-collapse: collapse; - font-size: 0.95rem; + font-size: 0.92rem; } .timetable thead { - background: #f1f5f9; - border-bottom: 2px solid rgba(148, 163, 184, 0.3); + background: var(--surface-3); } .timetable th { - padding: 12px; + padding: 12px 14px; text-align: left; - font-weight: 600; - color: #0f172a; + font-weight: 650; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + border-bottom: 2px solid var(--border); } .timetable td { - padding: 12px; - border-bottom: 1px solid rgba(148, 163, 184, 0.15); + padding: 12px 14px; + border-bottom: 1px solid var(--border); + vertical-align: top; } .timetable tbody tr:hover { @@ -303,74 +980,260 @@ button.danger:hover { } .time-cell { - font-weight: 600; - color: #4f46e5; - width: 140px; + font-weight: 650; + color: var(--primary); + width: 150px; white-space: nowrap; - background: #f8fafc; -} - -.schedule-cell { - background: white; + background: var(--surface-2); } .schedule-items { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 10px; min-height: 60px; align-content: flex-start; } .schedule-card { - background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.06)); - border: 1px solid rgba(99, 102, 241, 0.25); - border-radius: 8px; - padding: 10px; + background: linear-gradient(135deg, var(--primary-soft), rgba(14, 165, 233, 0.08)); + border: 1px solid var(--primary-soft-2); + border-radius: var(--radius-sm); + padding: 12px; flex: 0 1 auto; - min-width: 180px; - cursor: grab; - transition: transform 0.12s ease, box-shadow 0.12s ease; + min-width: 210px; + transition: box-shadow 0.15s ease, transform 0.15s ease; } .schedule-card:hover { transform: translateY(-2px); - box-shadow: 0 8px 16px rgba(99, 102, 241, 0.15); -} - -.schedule-card:active { - cursor: grabbing; + box-shadow: var(--shadow-md); } .schedule-header { display: flex; justify-content: space-between; align-items: center; + gap: 8px; margin-bottom: 6px; } .schedule-header strong { - color: #4f46e5; + color: var(--primary); font-size: 0.95rem; } .schedule-time { - font-size: 0.85rem; - color: #64748b; - background: rgba(255, 255, 255, 0.5); - padding: 2px 6px; - border-radius: 4px; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.7); + padding: 3px 8px; + border-radius: 6px; + white-space: nowrap; } .schedule-details { font-size: 0.85rem; - color: #475569; + color: var(--text-muted); } -.schedule-plate { - color: #94a3b8; - font-size: 0.8rem; - font-family: monospace; +.schedule-details > div { margin-top: 2px; } +.schedule-plate { + color: var(--text-faint); + font-size: 0.8rem; + font-family: var(--font-mono); +} + +.schedule-expand { + width: 100%; + margin-top: 10px; + padding: 8px; + font-size: 0.82rem; + background: rgba(255, 255, 255, 0.65); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text); + cursor: pointer; + text-align: center; +} + +.schedule-expand:hover { + background: var(--surface); + box-shadow: var(--shadow-sm); +} + +.stop-table { + width: 100%; + font-size: 0.82rem; + border-collapse: collapse; + margin-top: 10px; +} + +.stop-table td { + padding: 4px 6px; + border-bottom: 1px solid var(--border); +} + +.stop-table tr:last-child td { + border-bottom: none; +} + +/* ============================================================ + Weekly panels + ============================================================ */ + +.weekly-panels { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 20px; + align-items: start; +} + +.weekly-panel { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + padding: 14px; +} + +.weekly-panel h4 { + margin: 0 0 10px; + font-size: 0.95rem; +} + +@media (max-width: 900px) { + .weekly-panels { + grid-template-columns: 1fr; + } +} + +/* ============================================================ + Login / auth + ============================================================ */ + +.auth-page { + min-height: 100vh; + display: grid; + place-items: center; + background: + radial-gradient(circle at 15% 20%, rgba(99, 102, 241, 0.16), transparent 34%), + radial-gradient(circle at 85% 75%, rgba(14, 165, 233, 0.14), transparent 30%), + linear-gradient(180deg, #f1f5f9 0%, #e0e7ff 100%); + padding: 24px; +} + +.auth-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 420px; + padding: 36px 34px 30px; +} + +.auth-brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 26px; + text-align: center; +} + +.auth-brand .brand-mark { + width: 56px; + height: 56px; + border-radius: 16px; +} + +.auth-brand h1 { + margin: 6px 0 0; + font-size: 1.4rem; + letter-spacing: -0.02em; +} + +.auth-brand p { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.auth-card form { + display: grid; + gap: 14px; +} + +.auth-card label { + display: grid; + gap: 6px; + font-size: 0.85rem; + font-weight: 550; + color: var(--text-muted); +} + +.auth-card input { + width: 100%; + flex: none; + min-width: 0; + padding: 12px 14px; + border-radius: var(--radius-xs); +} + +.auth-card .auth-submit { + margin-top: 4px; + padding: 12px; + font-size: 0.95rem; + border-radius: var(--radius-xs); +} + +.auth-footer { + text-align: center; + margin-top: 18px; + color: var(--text-faint); + font-size: 0.82rem; +} + +/* ============================================================ + Responsive + ============================================================ */ + +@media (max-width: 1024px) { + .sidebar { + width: 76px; + padding: 18px 10px 14px; + } + .brand-text, + .nav-label, + .nav-item span, + .user-meta { + display: none; + } + .nav-item { + justify-content: center; + padding: 12px; + } + .sidebar-footer .user-chip { + flex-direction: column; + gap: 6px; + } + .topbar { + padding: 16px 18px 12px; + } + .content { + padding: 10px 18px 40px; + } +} + +@media (max-width: 640px) { + .topbar { + flex-direction: column; + align-items: flex-start; + } +} \ No newline at end of file