M1: implement operational core
Demo auth, seed import/reset, dashboard, vehicle/booking list+detail, audit trail. Backend: 19 tests passing, ruff clean. Frontend: React Router shell, typed API client, responsive pages. Verified end-to-end via curl and browser.
This commit is contained in:
+32
-44
@@ -1,49 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const API = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
type Status = {
|
||||
service: string;
|
||||
environment: string;
|
||||
demo_mode: boolean;
|
||||
knowledge_provider: string;
|
||||
scaffold: boolean;
|
||||
};
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Vehicles } from "./pages/Vehicles";
|
||||
import { VehicleDetail } from "./pages/VehicleDetail";
|
||||
import { Bookings } from "./pages/Bookings";
|
||||
import { BookingDetail } from "./pages/BookingDetail";
|
||||
import { Audit } from "./pages/Audit";
|
||||
|
||||
export function App() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API}/api/v1/system/status`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`API returned ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(setStatus)
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Unknown error"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero">
|
||||
<p className="eyebrow">Synthetic proof of concept</p>
|
||||
<h1>MobilityOps</h1>
|
||||
<p>Connected operations for vehicle rental and service teams.</p>
|
||||
</section>
|
||||
<section className="panel" aria-live="polite">
|
||||
<h2>Scaffold status</h2>
|
||||
{error && <p className="error">API unavailable: {error}</p>}
|
||||
{!error && !status && <p>Connecting to the MobilityOps API…</p>}
|
||||
{status && (
|
||||
<dl>
|
||||
<div><dt>Service</dt><dd>{status.service}</dd></div>
|
||||
<div><dt>Environment</dt><dd>{status.environment}</dd></div>
|
||||
<div><dt>Knowledge provider</dt><dd>{status.knowledge_provider}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
<p className="note">This is the bootable project scaffold. Claude must replace it with the complete scoped application described in the build pack.</p>
|
||||
</section>
|
||||
</main>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
|
||||
constructor(status: number, code: string, message: string, correlationId: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.correlationId = correlationId;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body: { error?: { code: string; message: string; correlation_id: string } } | undefined;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
const error = body?.error;
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.code ?? String(response.status),
|
||||
error?.message ?? response.statusText,
|
||||
error?.correlation_id ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
export type Role = "operations_manager" | "rental_employee";
|
||||
|
||||
export interface CurrentUser {
|
||||
public_ref: string;
|
||||
display_name: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
public_ref: string;
|
||||
make: string;
|
||||
model: string;
|
||||
model_year: number;
|
||||
registration_number: string;
|
||||
location: string;
|
||||
operational_status: string;
|
||||
odometer_km: number;
|
||||
next_service_km: number;
|
||||
active: boolean;
|
||||
attention: boolean;
|
||||
}
|
||||
|
||||
export interface BookingSummary {
|
||||
public_ref: string;
|
||||
customer_ref: string;
|
||||
vehicle_ref: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Booking extends BookingSummary {
|
||||
start_odometer_km: number | null;
|
||||
end_odometer_km: number | null;
|
||||
requirements_complete: boolean;
|
||||
customer_name: string;
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
public_ref: string;
|
||||
booking_ref: string;
|
||||
type: string;
|
||||
fuel_level_percent: number;
|
||||
cleanliness_ok: boolean;
|
||||
damage_reported: boolean;
|
||||
technical_warning: boolean;
|
||||
odometer_km: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceRecord {
|
||||
public_ref: string;
|
||||
occurred_at: string;
|
||||
odometer_km: number;
|
||||
category: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface DataQualityIssue {
|
||||
public_ref: string;
|
||||
rule_type: string;
|
||||
entity_type: string;
|
||||
entity_ref: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
status: "open" | "deferred" | "resolved" | "rejected";
|
||||
evidence: Record<string, unknown>;
|
||||
detected_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface VehicleDetail extends Vehicle {
|
||||
bookings: BookingSummary[];
|
||||
inspections: Inspection[];
|
||||
maintenance: MaintenanceRecord[];
|
||||
quality_issues: DataQualityIssue[];
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
available: number;
|
||||
rented: number;
|
||||
cleaning: number;
|
||||
maintenance: number;
|
||||
blocked: number;
|
||||
open_quality_issues: number;
|
||||
pending_or_failed_workflows: number;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
kind: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
title: string;
|
||||
detail: string;
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
}
|
||||
|
||||
export interface TodayItem {
|
||||
kind: "departure" | "return";
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
scheduled_at: string;
|
||||
}
|
||||
|
||||
export interface AutomationRun {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
aggregate_ref: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
metrics: DashboardMetrics;
|
||||
attention_items: AttentionItem[];
|
||||
today: TodayItem[];
|
||||
recent_automation: AutomationRun[];
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
actor_label: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
correlation_id: string;
|
||||
occurred_at: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
||||
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low";
|
||||
return <span className={`badge severity-${severity}`}>{label} severity</span>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/dashboard", label: "Dashboard" },
|
||||
{ to: "/vehicles", label: "Vehicles" },
|
||||
{ to: "/bookings", label: "Bookings" },
|
||||
{ to: "/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<p className="demo-banner">
|
||||
Synthetic demo environment — no real customer or vehicle data.
|
||||
</p>
|
||||
<header className="app-header">
|
||||
<span className="brand">MobilityOps</span>
|
||||
<nav aria-label="Primary">
|
||||
<ul>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} className={({ isActive }) => (isActive ? "active" : "")}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="user-badge">
|
||||
{user && (
|
||||
<>
|
||||
<span>
|
||||
{user.display_name} · {user.role === "operations_manager" ? "Operations Manager" : "Rental Employee"}
|
||||
</span>
|
||||
<button type="button" onClick={handleLogout}>
|
||||
Switch role
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { CurrentUser, Role } from "../api/types";
|
||||
|
||||
interface AuthState {
|
||||
user: CurrentUser | null;
|
||||
loading: boolean;
|
||||
loginAs: (role: Role) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = "mobilityops.demo-user";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<CurrentUser | null>(() => {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loginAs = useCallback(async (role: Role) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
|
||||
setUser(loggedIn);
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function isSessionExpired(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { AuditEvent } from "../api/types";
|
||||
|
||||
export function Audit() {
|
||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [action, setAction] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (action) params.set("action", action);
|
||||
api
|
||||
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
||||
.then(setEvents)
|
||||
.catch(() => setError("Audit trail is unavailable right now."));
|
||||
}, [action]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Audit</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter audit events">
|
||||
<label>
|
||||
Action
|
||||
<input
|
||||
type="text"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
placeholder="e.g. demo_login"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !events && <p>Loading audit trail…</p>}
|
||||
{events && events.length === 0 && <p>No audit events match this filter.</p>}
|
||||
|
||||
{events && events.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Audit events</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Actor</th>
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col">Entity</th>
|
||||
<th scope="col">Correlation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
<time dateTime={e.occurred_at}>
|
||||
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
</time>
|
||||
</td>
|
||||
<td>{e.actor_label} ({e.actor_type})</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.entity_type}</td>
|
||||
<td className="mono">{e.correlation_id.slice(0, 8)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
|
||||
export function BookingDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicRef) return;
|
||||
setBooking(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<Booking>(`/api/v1/bookings/${publicRef}`)
|
||||
.then(setBooking)
|
||||
.catch(() => setError("This booking could not be found."));
|
||||
}, [publicRef]);
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!booking) return <p>Loading booking…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<p><Link to="/bookings">← Back to bookings</Link></p>
|
||||
<h1>{booking.public_ref}</h1>
|
||||
<p><StatusBadge status={booking.status} /></p>
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
|
||||
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
||||
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
|
||||
<div><dt>Ends</dt><dd>{new Date(booking.ends_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
|
||||
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div>
|
||||
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
|
||||
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
|
||||
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
||||
|
||||
export function Bookings() {
|
||||
const [bookings, setBookings] = useState<Booking[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
api
|
||||
.get<Booking[]>(`/api/v1/bookings?${params.toString()}`)
|
||||
.then(setBookings)
|
||||
.catch(() => setError("Booking list is unavailable right now."));
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Bookings</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter bookings">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !bookings && <p>Loading bookings…</p>}
|
||||
{bookings && bookings.length === 0 && <p>No bookings match these filters.</p>}
|
||||
|
||||
{bookings && bookings.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Bookings</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reference</th>
|
||||
<th scope="col">Customer</th>
|
||||
<th scope="col">Vehicle</th>
|
||||
<th scope="col">Window</th>
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.public_ref}>
|
||||
<th scope="row">
|
||||
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
|
||||
</th>
|
||||
<td>{b.customer_name}</td>
|
||||
<td>
|
||||
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={b.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Dashboard as DashboardData } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
|
||||
const METRIC_LABELS: Record<keyof DashboardData["metrics"], string> = {
|
||||
available: "Available",
|
||||
rented: "Rented",
|
||||
cleaning: "Cleaning",
|
||||
maintenance: "Maintenance",
|
||||
blocked: "Blocked",
|
||||
open_quality_issues: "Open quality issues",
|
||||
pending_or_failed_workflows: "Pending/failed workflows",
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<DashboardData>("/api/v1/dashboard")
|
||||
.then(setData)
|
||||
.catch(() => setError("Dashboard data is unavailable right now."));
|
||||
}, []);
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!data) return <p>Loading dashboard…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<section aria-labelledby="metrics-heading">
|
||||
<h2 id="metrics-heading">Operational metrics</h2>
|
||||
<ul className="metric-grid">
|
||||
{(Object.keys(METRIC_LABELS) as (keyof DashboardData["metrics"])[]).map((key) => (
|
||||
<li key={key} className="metric-tile">
|
||||
<span className="metric-value">{data.metrics[key]}</span>
|
||||
<span className="metric-label">{METRIC_LABELS[key]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="attention-heading" className="panel">
|
||||
<h2 id="attention-heading">Attention required</h2>
|
||||
{data.attention_items.length === 0 && <p>Nothing needs attention right now.</p>}
|
||||
<ul className="attention-list">
|
||||
{data.attention_items.map((item, index) => (
|
||||
<li key={`${item.link_ref}-${index}`}>
|
||||
<SeverityBadge severity={item.severity} />
|
||||
<div>
|
||||
<p className="attention-title">
|
||||
{item.link_type === "vehicle" ? (
|
||||
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</p>
|
||||
<p className="attention-detail">{item.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="today-heading" className="panel">
|
||||
<h2 id="today-heading">Today</h2>
|
||||
{data.today.length === 0 && <p>No departures or returns scheduled today.</p>}
|
||||
<ul className="today-list">
|
||||
{data.today.map((item) => (
|
||||
<li key={`${item.kind}-${item.booking_ref}`}>
|
||||
<span className="today-kind">{item.kind === "departure" ? "Departure" : "Return"}</span>
|
||||
<Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link>
|
||||
<span>{item.vehicle_ref}</span>
|
||||
<time dateTime={item.scheduled_at}>
|
||||
{new Date(item.scheduled_at).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "Europe/Brussels",
|
||||
})}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="automation-heading" className="panel">
|
||||
<h2 id="automation-heading">Recent automation</h2>
|
||||
{data.recent_automation.length === 0 && <p>No automation runs recorded yet.</p>}
|
||||
<ul className="automation-list">
|
||||
{data.recent_automation.map((run) => (
|
||||
<li key={run.event_id}>
|
||||
<StatusBadge status={run.status} />
|
||||
<span>{run.event_type}</span>
|
||||
<span>{run.aggregate_ref}</span>
|
||||
<time dateTime={run.occurred_at}>
|
||||
{new Date(run.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role } from "../api/types";
|
||||
|
||||
export function Login() {
|
||||
const { loginAs, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleLogin(role: Role) {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(role);
|
||||
navigate("/dashboard");
|
||||
} catch {
|
||||
setError("Could not start a demo session. The API may be unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<p className="demo-banner">
|
||||
Synthetic demo environment — no real customer or vehicle data.
|
||||
</p>
|
||||
<section className="login-hero">
|
||||
<p className="eyebrow">Synthetic proof of concept</p>
|
||||
<h1>MobilityOps</h1>
|
||||
<p>Connected operations for vehicle rental and service teams.</p>
|
||||
</section>
|
||||
<section className="login-panel panel" aria-labelledby="login-heading">
|
||||
<h2 id="login-heading">Choose a demo role</h2>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="login-options">
|
||||
<button type="button" disabled={loading} onClick={() => handleLogin("operations_manager")}>
|
||||
Open as Operations Manager
|
||||
</button>
|
||||
<p>See the dashboard, resolve data-quality issues, retry automation and reset the demo.</p>
|
||||
|
||||
<button type="button" disabled={loading} onClick={() => handleLogin("rental_employee")}>
|
||||
Open as Rental Employee
|
||||
</button>
|
||||
<p>Register vehicle returns and look up bookings, vehicles and procedures.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { VehicleDetail as VehicleDetailData } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
|
||||
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export function VehicleDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicRef) return;
|
||||
setVehicle(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
|
||||
.then(setVehicle)
|
||||
.catch(() => setError("This vehicle could not be found."));
|
||||
}, [publicRef]);
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!vehicle) return <p>Loading vehicle…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<p><Link to="/vehicles">← Back to vehicles</Link></p>
|
||||
<h1>
|
||||
{vehicle.public_ref} — {vehicle.make} {vehicle.model}
|
||||
</h1>
|
||||
<p>
|
||||
<StatusBadge status={vehicle.operational_status} />
|
||||
{vehicle.attention && <span className="badge severity-high">Needs attention</span>}
|
||||
</p>
|
||||
|
||||
<div role="tablist" aria-label="Vehicle sections" className="tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={tab === t}
|
||||
className={tab === t ? "active" : ""}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div>
|
||||
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div>
|
||||
<div><dt>Location</dt><dd>{vehicle.location}</dd></div>
|
||||
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div>
|
||||
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div>
|
||||
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{tab === "bookings" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.bookings.length === 0 && <li>No bookings recorded.</li>}
|
||||
{vehicle.bookings.map((b) => (
|
||||
<li key={b.public_ref}>
|
||||
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
|
||||
<StatusBadge status={b.status} />
|
||||
<span>
|
||||
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "inspections" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.inspections.length === 0 && <li>No inspections recorded.</li>}
|
||||
{vehicle.inspections.map((i) => (
|
||||
<li key={i.public_ref}>
|
||||
<span>{i.type}</span>
|
||||
<span>{i.odometer_km.toLocaleString("en-GB")} km</span>
|
||||
<span>Fuel {i.fuel_level_percent}%</span>
|
||||
{i.damage_reported && <span className="badge severity-high">Damage</span>}
|
||||
{i.technical_warning && <span className="badge severity-high">Technical warning</span>}
|
||||
<time dateTime={i.completed_at}>{new Date(i.completed_at).toLocaleDateString("en-GB")}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "maintenance" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.maintenance.length === 0 && <li>No maintenance records.</li>}
|
||||
{vehicle.maintenance.map((m) => (
|
||||
<li key={m.public_ref}>
|
||||
<span>{m.category}</span>
|
||||
<span>{m.summary}</span>
|
||||
<time dateTime={m.occurred_at}>{new Date(m.occurred_at).toLocaleDateString("en-GB")}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "quality" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.quality_issues.length === 0 && <li>No quality issues recorded.</li>}
|
||||
{vehicle.quality_issues.map((q) => (
|
||||
<li key={q.public_ref}>
|
||||
<SeverityBadge severity={q.severity} />
|
||||
<span>{q.rule_type.replace(/_/g, " ")}</span>
|
||||
<StatusBadge status={q.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Vehicle } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
|
||||
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
|
||||
|
||||
export function Vehicles() {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [attentionOnly, setAttentionOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (attentionOnly) params.set("attention_only", "true");
|
||||
api
|
||||
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
|
||||
.then(setVehicles)
|
||||
.catch(() => setError("Vehicle list is unavailable right now."));
|
||||
}, [status, attentionOnly]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Vehicles</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter vehicles">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attentionOnly}
|
||||
onChange={(e) => setAttentionOnly(e.target.checked)}
|
||||
/>
|
||||
Attention only
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !vehicles && <p>Loading vehicles…</p>}
|
||||
{vehicles && vehicles.length === 0 && <p>No vehicles match these filters.</p>}
|
||||
|
||||
{vehicles && vehicles.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Vehicle fleet</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reference</th>
|
||||
<th scope="col">Make / model</th>
|
||||
<th scope="col">Location</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Odometer (km)</th>
|
||||
<th scope="col">Attention</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v) => (
|
||||
<tr key={v.public_ref}>
|
||||
<th scope="row">
|
||||
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link>
|
||||
</th>
|
||||
<td>
|
||||
{v.make} {v.model} ({v.model_year})
|
||||
</td>
|
||||
<td>{v.location}</td>
|
||||
<td>
|
||||
<StatusBadge status={v.operational_status} />
|
||||
</td>
|
||||
<td>{v.odometer_km.toLocaleString("en-GB")}</td>
|
||||
<td>{v.attention ? "Needs attention" : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+175
-10
@@ -6,14 +6,179 @@
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
.shell { width: min(920px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; }
|
||||
.hero { margin-bottom: 28px; }
|
||||
|
||||
a { color: #1f5c8f; }
|
||||
:focus-visible { outline: 3px solid #1f5c8f; outline-offset: 2px; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-banner {
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
background: #14324f;
|
||||
color: #eaf2fb;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 24px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #dce3eb;
|
||||
}
|
||||
.brand { font-weight: 800; font-size: 1.15rem; color: #172033; }
|
||||
.app-header nav ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
.app-header nav a {
|
||||
display: inline-block;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
color: #375065;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-header nav a.active, .app-header nav a[aria-current="page"] {
|
||||
background: #e5eef9;
|
||||
color: #14324f;
|
||||
}
|
||||
.user-badge { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 0.9rem; }
|
||||
.user-badge button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cfd8e2;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#main-content { width: min(1080px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; flex: 1; }
|
||||
|
||||
.page h1 { margin-top: 0; }
|
||||
.page section { margin-bottom: 28px; }
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border: 1px solid #dce3eb;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 30px rgba(24, 40, 64, .05);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
.metric-tile {
|
||||
background: white;
|
||||
border: 1px solid #dce3eb;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.metric-value { font-size: 1.8rem; font-weight: 800; }
|
||||
.metric-label { color: #607084; font-size: 0.85rem; }
|
||||
|
||||
.attention-list, .today-list, .automation-list, .record-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.attention-list li { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid #edf1f5; }
|
||||
.attention-title { margin: 0; font-weight: 700; }
|
||||
.attention-detail { margin: 2px 0 0; color: #607084; font-size: 0.9rem; }
|
||||
|
||||
.today-list li, .automation-list li, .record-list li {
|
||||
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
|
||||
padding: 8px 0; border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.severity-high { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
|
||||
.severity-medium { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; }
|
||||
.severity-low { background: #eaf1fb; color: #315d73; border-color: #c4d9ec; }
|
||||
.status-open, .status-pending, .status-active, .status-failed { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; }
|
||||
.status-failed { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
|
||||
.status-succeeded, .status-resolved, .status-available, .status-returned { background: #e6f5ec; color: #1f6d3d; border-color: #b9e0c9; }
|
||||
.status-blocked, .status-rejected { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
|
||||
.status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-delivering {
|
||||
background: #eef1f5; color: #47566b; border-color: #d7dfe8;
|
||||
}
|
||||
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 16px; align-items: end; margin-bottom: 16px; }
|
||||
.filters label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; color: #375065; font-weight: 600; }
|
||||
.filters select, .filters input[type="text"] { padding: 8px 10px; border: 1px solid #cfd8e2; border-radius: 8px; font-size: 0.95rem; }
|
||||
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
|
||||
|
||||
.data-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dce3eb; border-radius: 12px; overflow: hidden; }
|
||||
.data-table th, .data-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #edf1f5; font-size: 0.92rem; }
|
||||
.data-table thead th { background: #f6f8fb; color: #47566b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid #dce3eb; margin: 16px 0; }
|
||||
.tabs button {
|
||||
padding: 8px 14px; border: none; background: none; cursor: pointer;
|
||||
border-bottom: 3px solid transparent; font-weight: 600; color: #607084;
|
||||
}
|
||||
.tabs button.active { color: #14324f; border-bottom-color: #1f5c8f; }
|
||||
|
||||
.detail-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.detail-grid div { background: white; border: 1px solid #dce3eb; border-radius: 10px; padding: 10px 14px; }
|
||||
.detail-grid dt { color: #607084; font-size: 0.8rem; margin: 0; }
|
||||
.detail-grid dd { margin: 4px 0 0; font-weight: 700; }
|
||||
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.error { color: #9a2530; font-weight: 600; }
|
||||
|
||||
.login-shell { width: min(720px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; }
|
||||
.login-hero { margin-bottom: 24px; }
|
||||
.eyebrow { color: #315d73; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; font-size: .8rem; }
|
||||
h1 { margin: 6px 0; font-size: clamp(2.4rem, 7vw, 4.8rem); line-height: 1; }
|
||||
.hero > p:last-child { color: #526173; font-size: 1.2rem; }
|
||||
.panel { background: white; border: 1px solid #dce3eb; border-radius: 18px; padding: 24px; box-shadow: 0 10px 30px rgba(24,40,64,.06); }
|
||||
dl { display: grid; gap: 12px; }
|
||||
dl div { display: flex; justify-content: space-between; gap: 24px; border-bottom: 1px solid #edf1f5; padding-bottom: 10px; }
|
||||
dt { color: #607084; } dd { margin: 0; font-weight: 700; }
|
||||
.note { margin-top: 24px; color: #607084; }
|
||||
.error { color: #9a2530; }
|
||||
.login-hero h1 { margin: 6px 0; font-size: clamp(2.2rem, 7vw, 3.4rem); line-height: 1; }
|
||||
.login-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
.login-options button {
|
||||
padding: 14px 18px; border-radius: 12px; border: none;
|
||||
background: #14324f; color: white; font-weight: 700; font-size: 1rem; cursor: pointer;
|
||||
}
|
||||
.login-options button:hover, .login-options button:focus-visible { background: #1f5c8f; }
|
||||
.login-options p { margin: 0 0 8px; color: #607084; font-size: 0.9rem; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app-header { flex-direction: column; align-items: flex-start; }
|
||||
.user-badge { margin-left: 0; }
|
||||
.data-table, .data-table thead, .data-table tbody, .data-table th, .data-table td, .data-table tr {
|
||||
display: block;
|
||||
}
|
||||
.data-table thead { display: none; }
|
||||
.data-table tr { border-bottom: 2px solid #dce3eb; padding: 8px 0; }
|
||||
.data-table td, .data-table th { border: none; padding: 4px 12px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user