feat(sprint-3): refresh responsive UlHub portal
This commit is contained in:
12
web/.eslintrc.json
Normal file
12
web/.eslintrc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["pages/settings/mqtt-certs.tsx", "pages/sim/index.tsx"],
|
||||
"rules": {
|
||||
"@next/next/no-html-link-for-pages": "off",
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,66 +1,134 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { ReactNode } from "react";
|
||||
import { useAuth } from "../lib/auth-context";
|
||||
|
||||
export default function Layout({ children, title }: { children: ReactNode; title?: string }) {
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Jobs" },
|
||||
{ href: "/settings/devices", label: "Devices" },
|
||||
{ href: "/settings/members", label: "Members" },
|
||||
{ href: "/settings/api-keys", label: "API keys" },
|
||||
{ href: "/settings/mqtt-certs", label: "MQTT certs" },
|
||||
];
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = (href: string) =>
|
||||
href === "/"
|
||||
? router.pathname === "/" || router.pathname.startsWith("/jobs")
|
||||
: router.pathname.startsWith(href);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const navLinks = NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="ul-shell__nav-link"
|
||||
aria-current={isActive(item.href) ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title ? `${title} · UlHub` : 'UlHub'}</title>
|
||||
<title>{title ? `${title} · UlHub` : "UlHub"}</title>
|
||||
</Head>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.5rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderBottom: '1px solid #ddd',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<Link href="/" style={{ fontWeight: 700, fontSize: '1.1rem', textDecoration: 'none', color: '#111' }}>
|
||||
UlHub
|
||||
<a className="ul-skip-link" href="#main-content">
|
||||
Skip to main content
|
||||
</a>
|
||||
<header className="ul-shell__header">
|
||||
<Link href="/" className="ul-shell__brand" aria-label="UlHub jobs home">
|
||||
<span className="ul-shell__brand-mark" aria-hidden="true">
|
||||
UL
|
||||
</span>
|
||||
<span>UlHub</span>
|
||||
</Link>
|
||||
{user && (
|
||||
<>
|
||||
<nav style={{ display: 'flex', gap: '1rem' }}>
|
||||
<Link href="/">Jobs</Link>
|
||||
<Link href="/settings/devices">Devices</Link>
|
||||
<Link href="/settings/members">Members</Link>
|
||||
<Link href="/settings/api-keys">API Keys</Link>
|
||||
<Link href="/settings/mqtt-certs">MQTT Certs</Link>
|
||||
<nav
|
||||
className="ul-shell__nav ul-shell__nav--desktop"
|
||||
aria-label="Primary navigation"
|
||||
>
|
||||
{navLinks}
|
||||
</nav>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div className="ul-shell__context">
|
||||
<div className="ul-org-context">
|
||||
<span className="ul-org-context__label" id="active-org-label">
|
||||
Active organization
|
||||
</span>
|
||||
{memberships.length > 1 ? (
|
||||
<select value={activeOrg?.org.id ?? ''} onChange={(e) => setActiveOrgId(e.target.value)}>
|
||||
{memberships.map((m) => (
|
||||
<option key={m.org.id} value={m.org.id}>
|
||||
{m.org.name}
|
||||
<select
|
||||
className="ul-org-context__select"
|
||||
aria-labelledby="active-org-label"
|
||||
value={activeOrg?.org.id ?? ""}
|
||||
onChange={(event) => setActiveOrgId(event.target.value)}
|
||||
>
|
||||
{memberships.map((membership) => (
|
||||
<option key={membership.org.id} value={membership.org.id}>
|
||||
{membership.org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span style={{ color: '#555' }}>{activeOrg?.org.name}</span>
|
||||
<strong className="ul-org-context__name">
|
||||
{activeOrg?.org.name ?? "No organization"}
|
||||
</strong>
|
||||
)}
|
||||
<span style={{ color: '#888', fontSize: '0.9rem' }}>{user.email}</span>
|
||||
{activeOrg && (
|
||||
<span className="ul-org-context__role">
|
||||
{activeOrg.role.replace("_", " ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<details className="ul-shell__menu">
|
||||
<summary>Menu</summary>
|
||||
<nav
|
||||
className="ul-shell__nav ul-shell__nav--mobile"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
{navLinks}
|
||||
<div className="ul-shell__mobile-account">
|
||||
<span>{user.email}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
className="ul-button ul-button--quiet"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</details>
|
||||
<div className="ul-shell__account">
|
||||
<span className="ul-shell__email">{user.email}</span>
|
||||
<button
|
||||
className="ul-button ul-button--quiet"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<main style={{ fontFamily: 'sans-serif', padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>{children}</main>
|
||||
<main id="main-content" className="ul-shell__main" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
96
web/components/PortalUi.tsx
Normal file
96
web/components/PortalUi.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type StatusTone = "info" | "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
const STATUS_PRESENTATION: Record<
|
||||
string,
|
||||
{ icon: string; label: string; tone: StatusTone }
|
||||
> = {
|
||||
OPEN: { icon: "○", label: "Open", tone: "info" },
|
||||
IN_PROGRESS: { icon: "◐", label: "In progress", tone: "warning" },
|
||||
COMPLETED: { icon: "✓", label: "Completed", tone: "success" },
|
||||
CANCELLED: { icon: "—", label: "Cancelled", tone: "neutral" },
|
||||
LIVE: { icon: "●", label: "Live", tone: "success" },
|
||||
CONNECTING: { icon: "↻", label: "Connecting", tone: "info" },
|
||||
OFFLINE: { icon: "○", label: "Offline · reconnecting", tone: "warning" },
|
||||
ERROR: { icon: "!", label: "Connection error · retrying", tone: "danger" },
|
||||
};
|
||||
|
||||
export function humanizeStatus(status: string): string {
|
||||
return (
|
||||
STATUS_PRESENTATION[status]?.label ??
|
||||
status
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
}: {
|
||||
status: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const presentation = STATUS_PRESENTATION[status] ?? {
|
||||
icon: "•",
|
||||
label: humanizeStatus(status),
|
||||
tone: "neutral" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`ul-status-badge ul-status-badge--${presentation.tone}`}>
|
||||
<span className="ul-status-badge__icon" aria-hidden="true">
|
||||
{presentation.icon}
|
||||
</span>
|
||||
<span>{label ?? presentation.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageState({
|
||||
kind,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
kind: "loading" | "empty" | "error";
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const icon = kind === "error" ? "!" : kind === "empty" ? "□" : "…";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ul-page-state ul-page-state--${kind}`}
|
||||
role={kind === "error" ? "alert" : "status"}
|
||||
>
|
||||
<span className="ul-page-state__icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
{children && <div className="ul-page-state__detail">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
detail?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ul-metric">
|
||||
<span className="ul-metric__label">{label}</span>
|
||||
<strong className="ul-metric__value">{value}</strong>
|
||||
{detail && <span className="ul-metric__detail">{detail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type MapViewConstructor from '@arcgis/core/views/MapView.js';
|
||||
import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types';
|
||||
|
||||
const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || '';
|
||||
const LIVE_COLOR = '#1a73e8';
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat]
|
||||
const DEFAULT_ZOOM = 4;
|
||||
@@ -63,6 +62,10 @@ function colorFor(utilityType: string): string {
|
||||
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
|
||||
}
|
||||
|
||||
function semanticColor(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
function orderKey(p: MapPoint): number {
|
||||
return p.sequence ?? new Date(p.recordedAt).getTime();
|
||||
}
|
||||
@@ -78,10 +81,10 @@ function popupHtml(point: MapPoint): string {
|
||||
const rows = pointDetailRows(point)
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><td style="color:#666;padding-right:0.75rem;white-space:nowrap;">${label}</td><td style="font-weight:600;">${value}</td></tr>`,
|
||||
`<tr><td class="ul-map-popup-table__label">${label}</td><td class="ul-map-popup-table__value">${value}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
return `<table style="border-collapse:collapse;font-family:sans-serif;">${rows}</table>`;
|
||||
return `<table class="ul-map-popup-table">${rows}</table>`;
|
||||
}
|
||||
|
||||
// Esri opens/closes/dismisses-on-outside-click popups natively via each
|
||||
@@ -94,7 +97,7 @@ function pointGraphic(mods: EsriModules, point: MapPoint, color: string) {
|
||||
style: 'circle',
|
||||
color,
|
||||
size: 10,
|
||||
outline: { color: '#ffffff', width: 1.5 },
|
||||
outline: { color: semanticColor('--ul-color-on-primary'), width: 1.5 },
|
||||
},
|
||||
popupTemplate: {
|
||||
title: `${point.utilityType} · ${point.fixType}`,
|
||||
@@ -119,15 +122,16 @@ function lineGraphic(mods: EsriModules, points: MapPoint[], color: string) {
|
||||
}
|
||||
|
||||
function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
||||
const liveColor = semanticColor('--ul-color-primary');
|
||||
const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat };
|
||||
const marker = new mods.Graphic({
|
||||
geometry: position,
|
||||
symbol: {
|
||||
type: 'simple-marker',
|
||||
style: 'circle',
|
||||
color: LIVE_COLOR,
|
||||
color: liveColor,
|
||||
size: 16,
|
||||
outline: { color: '#ffffff', width: 2 },
|
||||
outline: { color: semanticColor('--ul-color-on-primary'), width: 2 },
|
||||
},
|
||||
popupTemplate: {
|
||||
title: `Live · ${status.serial}`,
|
||||
@@ -144,15 +148,15 @@ function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
||||
}),
|
||||
symbol: {
|
||||
type: 'simple-fill',
|
||||
color: [...hexToRgb(LIVE_COLOR), 0.15],
|
||||
outline: { color: [...hexToRgb(LIVE_COLOR), 0.3], width: 1 },
|
||||
color: [...hexChannels(liveColor), 0.15],
|
||||
outline: { color: [...hexChannels(liveColor), 0.3], width: 1 },
|
||||
},
|
||||
} as ConstructorParameters<typeof GraphicConstructor>[0]);
|
||||
|
||||
return [halo, marker];
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
function hexChannels(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
@@ -165,6 +169,9 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
const modsRef = useRef<EsriModules | null>(null);
|
||||
const fittedSignatureRef = useRef('');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const sizeClass = heightPx <= 360 ? 'ul-map--compact' : heightPx >= 500 ? 'ul-map--detail' : '';
|
||||
const mapClassName = ['ul-map', sizeClass].filter(Boolean).join(' ');
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !API_KEY) {
|
||||
@@ -173,7 +180,8 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
let cancelled = false;
|
||||
let view: InstanceType<typeof MapViewConstructor> | undefined;
|
||||
|
||||
loadEsriModules().then((mods) => {
|
||||
loadEsriModules()
|
||||
.then((mods) => {
|
||||
if (cancelled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -192,6 +200,12 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
liveLayerRef.current = liveLayer;
|
||||
viewRef.current = view;
|
||||
setReady(true);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!cancelled) {
|
||||
console.error('EsriJobMap: map failed to load', error);
|
||||
setLoadError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -267,25 +281,23 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
|
||||
if (!API_KEY) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: heightPx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed #999',
|
||||
borderRadius: 8,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<div className={`${mapClassName} ul-map__fallback`} role="status">
|
||||
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div style={{ height: heightPx, borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div ref={containerRef} style={{ height: '100%' }} />
|
||||
<div className={`${mapClassName} ul-map__fallback`} role="alert">
|
||||
The locate map could not be loaded. Check the network connection and ArcGIS configuration.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={mapClassName} role="region" aria-label="Interactive locate map" aria-busy={!ready}>
|
||||
<div ref={containerRef} className="ul-map__canvas" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { LiveStatus, MapPoint } from '../components/map/types';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LiveStatus, MapPoint } from "../components/map/types";
|
||||
|
||||
export type JobStreamState = "connecting" | "live" | "offline" | "error";
|
||||
|
||||
// Subscribes to live points + live position status for a job over the
|
||||
// backend WebSocket. Reconnects with capped exponential backoff; resubscribes
|
||||
@@ -9,6 +11,8 @@ export function useJobStream(
|
||||
onPoints: (points: MapPoint[]) => void,
|
||||
onStatus?: (status: LiveStatus) => void,
|
||||
) {
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<JobStreamState>("connecting");
|
||||
const pointsRef = useRef(onPoints);
|
||||
pointsRef.current = onPoints;
|
||||
const statusRef = useRef(onStatus);
|
||||
@@ -16,6 +20,7 @@ export function useJobStream(
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
setConnectionState("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,12 +30,17 @@ export function useJobStream(
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
let socketErrored = false;
|
||||
setConnectionState(attempt === 0 ? "connecting" : "offline");
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0;
|
||||
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` }));
|
||||
setConnectionState("live");
|
||||
socket?.send(
|
||||
JSON.stringify({ type: "subscribe", channel: `job:${jobId}` }),
|
||||
);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
@@ -39,9 +49,9 @@ export function useJobStream(
|
||||
if (msg.jobId !== jobId) {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'points') {
|
||||
if (msg.type === "points") {
|
||||
pointsRef.current(msg.points);
|
||||
} else if (msg.type === 'status') {
|
||||
} else if (msg.type === "status") {
|
||||
statusRef.current?.(msg as LiveStatus);
|
||||
}
|
||||
} catch {
|
||||
@@ -49,10 +59,18 @@ export function useJobStream(
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
socketErrored = true;
|
||||
setConnectionState("error");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (!socketErrored) {
|
||||
setConnectionState("offline");
|
||||
}
|
||||
attempt += 1;
|
||||
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
||||
timer = setTimeout(connect, delay);
|
||||
@@ -69,4 +87,6 @@ export function useJobStream(
|
||||
socket?.close();
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
return connectionState;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"esri-loader": "^3.7.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppProps } from 'next/app';
|
||||
import { AuthProvider } from '../lib/auth-context';
|
||||
import type { AppProps } from "next/app";
|
||||
import { AuthProvider } from "../lib/auth-context";
|
||||
import "../styles/global.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import { useRequireAuth } from '../lib/auth-context';
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import Layout from "../components/Layout";
|
||||
import { humanizeStatus, PageState, StatusBadge } from "../components/PortalUi";
|
||||
import { api } from "../lib/api";
|
||||
import { useRequireAuth } from "../lib/auth-context";
|
||||
|
||||
interface JobRow {
|
||||
id: string;
|
||||
@@ -16,103 +17,146 @@ interface JobRow {
|
||||
_count: { points: number };
|
||||
}
|
||||
|
||||
const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
OPEN: '#1976d2',
|
||||
IN_PROGRESS: '#f57c00',
|
||||
COMPLETED: '#388e3c',
|
||||
CANCELLED: '#9e9e9e',
|
||||
};
|
||||
const STATUSES = ["", "OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||
|
||||
export default function JobsPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [status, setStatus] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeOrg) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (q) params.set('q', q);
|
||||
if (status) params.set("status", status);
|
||||
if (q) params.set("q", q);
|
||||
setFetching(true);
|
||||
api
|
||||
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`)
|
||||
.get<{ jobs: JobRow[]; total: number }>(
|
||||
`/api/orgs/${activeOrg.org.id}/jobs?${params}`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setJobs(res.jobs);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setFetching(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeOrg, status, q]);
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
return <PageState kind="loading" title="Loading your workspace…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Jobs">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<h1 style={{ margin: 0 }}>Jobs</h1>
|
||||
<span style={{ color: '#888' }}>{total} total</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||
<input placeholder="Search ticket, title, address…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s || 'All statuses'}
|
||||
<header className="ul-page-header">
|
||||
<div className="ul-page-header__title-group">
|
||||
<span className="ul-page-header__eyebrow">Operations</span>
|
||||
<h1>Jobs</h1>
|
||||
<p className="ul-page-header__meta" aria-live="polite">
|
||||
{fetching
|
||||
? "Updating jobs…"
|
||||
: `${total} ${total === 1 ? "job" : "jobs"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/jobs/new" className="ul-button">
|
||||
<span aria-hidden="true">+</span> New job
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="ul-panel ul-filter-bar" aria-label="Job filters">
|
||||
<label className="ul-field">
|
||||
<span className="ul-field__label">Search jobs</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Ticket, title, or address"
|
||||
value={q}
|
||||
onChange={(event) => setQ(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="ul-field">
|
||||
<span className="ul-field__label">Status</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
>
|
||||
{STATUSES.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item ? humanizeStatus(item) : "All statuses"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Link href="/jobs/new">
|
||||
<button>+ New job</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
{error ? (
|
||||
<PageState kind="error" title="Jobs could not be loaded">
|
||||
{error}
|
||||
</PageState>
|
||||
) : jobs.length === 0 && !fetching ? (
|
||||
<PageState kind="empty" title="No jobs found">
|
||||
{q || status
|
||||
? "Try clearing or changing the current filters."
|
||||
: "Create a job, or let a field device post points to auto-create its ticket."}
|
||||
</PageState>
|
||||
) : (
|
||||
<div className="ul-panel ul-table-wrap" aria-busy={fetching}>
|
||||
<table className="ul-table">
|
||||
<caption className="ul-visually-hidden">
|
||||
Jobs for the active organization
|
||||
</caption>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Ticket</th>
|
||||
<th>Title</th>
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
<th>Assignee</th>
|
||||
<th style={{ textAlign: 'right' }}>Points</th>
|
||||
<tr>
|
||||
<th scope="col">Ticket</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Address</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Assignee</th>
|
||||
<th scope="col" className="ul-table__numeric">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<Link href={`/jobs/${job.id}`}>{job.ticketNumber}</Link>
|
||||
<tr key={job.id}>
|
||||
<td data-label="Ticket">
|
||||
<Link className="ul-ticket-link" href={`/jobs/${job.id}`}>
|
||||
{job.ticketNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{job.title}</td>
|
||||
<td>{job.address ?? '—'}</td>
|
||||
<td>
|
||||
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
|
||||
<td data-label="Title">{job.title}</td>
|
||||
<td data-label="Address">{job.address ?? "—"}</td>
|
||||
<td data-label="Status">
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
<td data-label="Source">{humanizeStatus(job.source)}</td>
|
||||
<td data-label="Assignee">{job.assignedTo?.name ?? "—"}</td>
|
||||
<td data-label="Points" className="ul-table__numeric">
|
||||
{job._count.points}
|
||||
</td>
|
||||
<td>{job.source}</td>
|
||||
<td>{job.assignedTo?.name ?? '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{job._count.points}</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && !error && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
|
||||
No jobs yet. Create one, or let a device post points to auto-create its ticket.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import JobMap from '../../components/map/JobMap';
|
||||
import { pointSummary, type LiveStatus, type MapPoint } from '../../components/map/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useRequireAuth } from '../../lib/auth-context';
|
||||
import { useJobStream } from '../../lib/use-job-stream';
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Layout from "../../components/Layout";
|
||||
import {
|
||||
humanizeStatus,
|
||||
Metric,
|
||||
PageState,
|
||||
StatusBadge,
|
||||
} from "../../components/PortalUi";
|
||||
import JobMap from "../../components/map/JobMap";
|
||||
import {
|
||||
pointSummary,
|
||||
type LiveStatus,
|
||||
type MapPoint,
|
||||
} from "../../components/map/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { useRequireAuth } from "../../lib/auth-context";
|
||||
import { useJobStream } from "../../lib/use-job-stream";
|
||||
|
||||
interface JobDetail {
|
||||
id: string;
|
||||
@@ -21,12 +32,13 @@ interface JobDetail {
|
||||
_count: { points: number };
|
||||
}
|
||||
|
||||
const STATUSES = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
const STATUSES = ["OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const { user, activeOrg, loading } = useRequireAuth();
|
||||
const router = useRouter();
|
||||
const jobId = typeof router.query.jobId === 'string' ? router.query.jobId : null;
|
||||
const jobId =
|
||||
typeof router.query.jobId === "string" ? router.query.jobId : null;
|
||||
const orgId = activeOrg?.org.id ?? null;
|
||||
|
||||
const [job, setJob] = useState<JobDetail | null>(null);
|
||||
@@ -34,19 +46,40 @@ export default function JobDetailPage() {
|
||||
const [live, setLive] = useState(0);
|
||||
const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId || !jobId) {
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`)
|
||||
.then(setJob)
|
||||
.catch((err) => setError(err.message));
|
||||
api
|
||||
.get<{ points: MapPoint[] }>(`/api/orgs/${orgId}/jobs/${jobId}/points`)
|
||||
.then((res) => setPoints(res.points))
|
||||
.catch((err) => setError(err.message));
|
||||
let cancelled = false;
|
||||
setJob(null);
|
||||
setPoints([]);
|
||||
setLive(0);
|
||||
setLiveStatus(null);
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
Promise.all([
|
||||
api.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`),
|
||||
api.get<{ points: MapPoint[] }>(
|
||||
`/api/orgs/${orgId}/jobs/${jobId}/points`,
|
||||
),
|
||||
])
|
||||
.then(([jobResult, pointResult]) => {
|
||||
if (cancelled) return;
|
||||
setJob(jobResult);
|
||||
setPoints(pointResult.points);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setFetching(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orgId, jobId]);
|
||||
|
||||
const onLivePoints = useCallback((incoming: MapPoint[]) => {
|
||||
@@ -58,67 +91,163 @@ export default function JobDetailPage() {
|
||||
setLive((n) => n + incoming.length);
|
||||
}, []);
|
||||
|
||||
const onStatus = useCallback((status: LiveStatus) => setLiveStatus(status), []);
|
||||
const onStatus = useCallback(
|
||||
(status: LiveStatus) => setLiveStatus(status),
|
||||
[],
|
||||
);
|
||||
|
||||
useJobStream(jobId, onLivePoints, onStatus);
|
||||
const streamState = useJobStream(jobId, onLivePoints, onStatus);
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
if (!orgId || !jobId) {
|
||||
return;
|
||||
}
|
||||
setUpdatingStatus(true);
|
||||
try {
|
||||
setJob(await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, { status }));
|
||||
setJob(
|
||||
await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, {
|
||||
status,
|
||||
}),
|
||||
);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !user) {
|
||||
return null;
|
||||
return <PageState kind="loading" title="Loading your workspace…" />;
|
||||
}
|
||||
|
||||
const streamStatus = streamState.toUpperCase();
|
||||
const streamLabel =
|
||||
streamState === "live"
|
||||
? live > 0
|
||||
? `Live · ${live} new ${live === 1 ? "point" : "points"} this session`
|
||||
: "Live · waiting for activity"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Layout title={job ? job.ticketNumber : 'Job'}>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
{job && (
|
||||
<Layout title={job ? job.ticketNumber : "Job"}>
|
||||
<nav className="ul-breadcrumb" aria-label="Breadcrumb">
|
||||
<Link href="/">← All jobs</Link>
|
||||
</nav>
|
||||
|
||||
{error && !job ? (
|
||||
<PageState kind="error" title="Job could not be loaded">
|
||||
{error}
|
||||
</PageState>
|
||||
) : fetching || !job ? (
|
||||
<PageState kind="loading" title="Loading job and locate points…" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<h1 style={{ margin: 0 }}>{job.ticketNumber}</h1>
|
||||
<span style={{ fontSize: '1.1rem' }}>{job.title}</span>
|
||||
<select value={job.status} onChange={(e) => updateStatus(e.target.value)} style={{ marginLeft: 'auto' }}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
<header className="ul-detail-header">
|
||||
<div>
|
||||
<span className="ul-detail-header__eyebrow">Locate job</span>
|
||||
<div className="ul-detail-header__title-row">
|
||||
<h1>{job.ticketNumber}</h1>
|
||||
<span className="ul-detail-header__title">{job.title}</span>
|
||||
</div>
|
||||
<p className="ul-detail-header__subtitle">
|
||||
{job.address ?? "No address"} · {humanizeStatus(job.source)}{" "}
|
||||
source
|
||||
</p>
|
||||
</div>
|
||||
<label className="ul-field">
|
||||
<span className="ul-field__label">Job status</span>
|
||||
<select
|
||||
value={job.status}
|
||||
disabled={updatingStatus}
|
||||
aria-describedby="job-status-update"
|
||||
onChange={(event) => updateStatus(event.target.value)}
|
||||
>
|
||||
{STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{humanizeStatus(status)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p style={{ color: '#666' }}>
|
||||
{job.address && <>{job.address} · </>}
|
||||
source {job.source} · created {new Date(job.createdAt).toLocaleString()}
|
||||
{job.assignedTo && <> · assigned to {job.assignedTo.name}</>}
|
||||
{job.dueAt && <> · locate by {new Date(job.dueAt).toLocaleString()}</>}
|
||||
</p>
|
||||
{job.description && <p>{job.description}</p>}
|
||||
<span
|
||||
id="job-status-update"
|
||||
className="ul-visually-hidden"
|
||||
aria-live="polite"
|
||||
>
|
||||
{updatingStatus
|
||||
? "Updating job status"
|
||||
: `Current status: ${humanizeStatus(job.status)}`}
|
||||
</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555', flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
<strong>{points.length}</strong> points
|
||||
</span>
|
||||
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
||||
● live{live > 0 ? ` (+${live} this session)` : ''}
|
||||
</span>
|
||||
{error && (
|
||||
<PageState kind="error" title="The latest update failed">
|
||||
{error}
|
||||
</PageState>
|
||||
)}
|
||||
|
||||
<section className="ul-panel ul-job-summary" aria-label="Job summary">
|
||||
<Metric
|
||||
label="Status"
|
||||
value={<StatusBadge status={job.status} />}
|
||||
/>
|
||||
<Metric
|
||||
label="Created"
|
||||
value={new Date(job.createdAt).toLocaleDateString()}
|
||||
detail={new Date(job.createdAt).toLocaleTimeString()}
|
||||
/>
|
||||
<Metric
|
||||
label="Locate by"
|
||||
value={
|
||||
job.dueAt ? new Date(job.dueAt).toLocaleDateString() : "Not set"
|
||||
}
|
||||
detail={
|
||||
job.dueAt ? new Date(job.dueAt).toLocaleTimeString() : undefined
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label="Assigned to"
|
||||
value={job.assignedTo?.name ?? "Unassigned"}
|
||||
detail={job.assignedTo?.email}
|
||||
/>
|
||||
<Metric label="Source" value={humanizeStatus(job.source)} />
|
||||
<Metric label="Recorded points" value={points.length} />
|
||||
{job.description && (
|
||||
<p className="ul-job-summary__description">{job.description}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="ul-live-strip" aria-live="polite">
|
||||
<StatusBadge status={streamStatus} label={streamLabel} />
|
||||
{points.length > 0 && (
|
||||
<span style={{ color: '#777' }}>latest: {pointSummary(points[points.length - 1])}</span>
|
||||
<span className="ul-live-strip__latest">
|
||||
Latest point: {pointSummary(points[points.length - 1])}
|
||||
</span>
|
||||
)}
|
||||
{liveStatus && (
|
||||
<span style={{ color: '#1a73e8' }}>
|
||||
● transmitter {liveStatus.serial} live ({new Date(liveStatus.recordedAt).toLocaleTimeString()})
|
||||
</span>
|
||||
<StatusBadge
|
||||
status="LIVE"
|
||||
label={`Transmitter ${liveStatus.serial} · ${new Date(liveStatus.recordedAt).toLocaleTimeString()}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="ul-map-section"
|
||||
aria-labelledby="live-map-heading"
|
||||
>
|
||||
<div className="ul-section-heading">
|
||||
<div>
|
||||
<span className="ul-section-heading__eyebrow">
|
||||
Live operations
|
||||
</span>
|
||||
<h2 id="live-map-heading">Locate map</h2>
|
||||
</div>
|
||||
<StatusBadge status={streamStatus} />
|
||||
</div>
|
||||
<JobMap points={points} liveStatus={liveStatus} heightPx={520} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
860
web/styles/global.css
Normal file
860
web/styles/global.css
Normal file
@@ -0,0 +1,860 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--portal-canvas: var(--ul-color-canvas);
|
||||
--portal-surface: var(--ul-color-surface);
|
||||
--portal-surface-subtle: var(--ul-color-surface-raised);
|
||||
--portal-map-field: var(--ul-color-map);
|
||||
--portal-text: var(--ul-color-text);
|
||||
--portal-text-muted: var(--ul-color-text-muted);
|
||||
--portal-primary: var(--ul-color-primary);
|
||||
--portal-on-primary: var(--ul-color-on-primary);
|
||||
--portal-attention: var(--ul-color-accent);
|
||||
--portal-success: var(--ul-color-success);
|
||||
--portal-warning: var(--ul-color-warning);
|
||||
--portal-danger: var(--ul-color-error);
|
||||
--portal-border: var(--ul-color-border);
|
||||
--portal-border-strong: var(--ul-color-text-muted);
|
||||
--portal-focus: var(--ul-color-focus);
|
||||
--portal-font-ui: var(--ul-type-family-sans);
|
||||
--portal-font-mono: var(--ul-type-family-mono);
|
||||
--portal-space-1: var(--ul-space-2xs);
|
||||
--portal-space-2: var(--ul-space-xs);
|
||||
--portal-space-3: var(--ul-space-sm);
|
||||
--portal-space-4: var(--ul-space-md);
|
||||
--portal-space-6: var(--ul-space-lg);
|
||||
--portal-space-8: var(--ul-space-xl);
|
||||
--portal-radius-sm: var(--ul-radius-sm);
|
||||
--portal-radius-md: var(--ul-radius-md);
|
||||
--portal-shadow: var(--ul-elevation-raised);
|
||||
--portal-motion: var(--ul-motion-base) ease-out;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--portal-canvas);
|
||||
color: var(--portal-text);
|
||||
font-family: var(--portal-font-ui);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--portal-canvas);
|
||||
color: var(--portal-text);
|
||||
font-family: var(--portal-font-ui);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--portal-primary);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input[type="button"],
|
||||
input[type="submit"] {
|
||||
min-height: var(--ul-touch-minimum);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
min-height: var(--ul-touch-minimum);
|
||||
border: 1px solid var(--portal-border-strong);
|
||||
border-radius: var(--portal-radius-sm);
|
||||
background: var(--portal-surface);
|
||||
padding: var(--portal-space-2) var(--portal-space-3);
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea, summary):focus-visible {
|
||||
outline: 3px solid var(--portal-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.ul-skip-link {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: var(--portal-space-2);
|
||||
left: var(--portal-space-2);
|
||||
translate: 0 -200%;
|
||||
border-radius: var(--portal-radius-sm);
|
||||
background: var(--portal-surface);
|
||||
padding: var(--portal-space-3) var(--portal-space-4);
|
||||
color: var(--portal-text);
|
||||
font-weight: 700;
|
||||
box-shadow: var(--portal-shadow);
|
||||
}
|
||||
|
||||
.ul-skip-link:focus {
|
||||
translate: 0;
|
||||
}
|
||||
|
||||
.ul-shell__header {
|
||||
position: sticky;
|
||||
z-index: 50;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 4.5rem;
|
||||
align-items: center;
|
||||
gap: var(--portal-space-6);
|
||||
border-bottom: 1px solid var(--portal-border);
|
||||
background: var(--portal-surface);
|
||||
padding: var(--portal-space-3) clamp(1rem, 3vw, 2rem);
|
||||
box-shadow: var(--portal-shadow);
|
||||
}
|
||||
|
||||
.ul-shell__brand {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
align-items: center;
|
||||
gap: var(--portal-space-2);
|
||||
color: var(--portal-text);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ul-shell__brand-mark {
|
||||
display: inline-grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
place-items: center;
|
||||
border-radius: var(--portal-radius-sm);
|
||||
background: var(--portal-primary);
|
||||
color: var(--portal-on-primary);
|
||||
font-family: var(--portal-font-mono);
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ul-shell__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--portal-space-1);
|
||||
}
|
||||
|
||||
.ul-shell__nav-link {
|
||||
display: inline-flex;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
align-items: center;
|
||||
border-radius: var(--portal-radius-sm);
|
||||
padding: 0 var(--portal-space-3);
|
||||
color: var(--portal-text-muted);
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ul-shell__nav-link:hover,
|
||||
.ul-shell__nav-link[aria-current="page"] {
|
||||
background: var(--portal-surface-subtle);
|
||||
color: var(--portal-text);
|
||||
}
|
||||
|
||||
.ul-shell__nav-link[aria-current="page"] {
|
||||
box-shadow: inset 0 -0.2rem var(--portal-primary);
|
||||
}
|
||||
|
||||
.ul-shell__context {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-org-context {
|
||||
display: grid;
|
||||
min-width: 10rem;
|
||||
grid-template-columns: minmax(0, auto) auto;
|
||||
align-items: center;
|
||||
column-gap: var(--portal-space-2);
|
||||
}
|
||||
|
||||
.ul-org-context__label {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ul-org-context__select {
|
||||
max-width: 13rem;
|
||||
height: var(--ul-touch-minimum);
|
||||
min-height: var(--ul-touch-minimum);
|
||||
padding-block: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ul-org-context__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ul-org-context__role {
|
||||
border: 1px solid var(--portal-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 var(--portal-space-2);
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.ul-shell__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--portal-space-2);
|
||||
}
|
||||
|
||||
.ul-shell__email {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.875rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ul-shell__menu {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ul-shell__menu summary {
|
||||
display: inline-flex;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--portal-border-strong);
|
||||
border-radius: var(--portal-radius-sm);
|
||||
padding: 0 var(--portal-space-3);
|
||||
font-weight: 700;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ul-shell__menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ul-shell__nav--mobile {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--portal-space-2));
|
||||
right: 0;
|
||||
width: min(18rem, calc(100vw - 2rem));
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--portal-border);
|
||||
border-radius: var(--portal-radius-md);
|
||||
background: var(--portal-surface);
|
||||
padding: var(--portal-space-2);
|
||||
box-shadow: var(--portal-shadow);
|
||||
}
|
||||
|
||||
.ul-shell__mobile-account {
|
||||
display: grid;
|
||||
gap: var(--portal-space-2);
|
||||
margin-top: var(--portal-space-2);
|
||||
border-top: 1px solid var(--portal-border);
|
||||
padding: var(--portal-space-3) var(--portal-space-2) var(--portal-space-2);
|
||||
color: var(--portal-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ul-shell__main {
|
||||
width: min(100% - 2rem, 75rem);
|
||||
margin-inline: auto;
|
||||
padding-block: clamp(1.25rem, 4vw, 2.5rem) 4rem;
|
||||
}
|
||||
|
||||
.ul-button {
|
||||
display: inline-flex;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--portal-radius-sm);
|
||||
background: var(--portal-primary);
|
||||
padding: 0 var(--portal-space-4);
|
||||
color: var(--portal-on-primary);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
filter var(--portal-motion),
|
||||
transform var(--portal-motion);
|
||||
}
|
||||
|
||||
.ul-button:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.ul-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.ul-button:disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ul-button--quiet {
|
||||
border-color: var(--portal-border);
|
||||
background: transparent;
|
||||
color: var(--portal-text);
|
||||
}
|
||||
|
||||
.ul-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--portal-space-4);
|
||||
margin-bottom: var(--portal-space-6);
|
||||
}
|
||||
|
||||
.ul-page-header__title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ul-page-header h1,
|
||||
.ul-detail-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.ul-page-header__eyebrow,
|
||||
.ul-section-heading__eyebrow,
|
||||
.ul-detail-header__eyebrow {
|
||||
display: block;
|
||||
margin-bottom: var(--portal-space-1);
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ul-page-header__meta,
|
||||
.ul-detail-header__subtitle {
|
||||
margin: var(--portal-space-1) 0 0;
|
||||
color: var(--portal-text-muted);
|
||||
}
|
||||
|
||||
.ul-panel {
|
||||
border: 1px solid var(--portal-border);
|
||||
border-radius: var(--portal-radius-md);
|
||||
background: var(--portal-surface);
|
||||
box-shadow: var(--portal-shadow);
|
||||
}
|
||||
|
||||
.ul-filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) minmax(10rem, auto);
|
||||
gap: var(--portal-space-3);
|
||||
margin-bottom: var(--portal-space-4);
|
||||
padding: var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-field {
|
||||
display: grid;
|
||||
gap: var(--portal-space-1);
|
||||
}
|
||||
|
||||
.ul-field__label {
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ul-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ul-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.ul-table th,
|
||||
.ul-table td {
|
||||
padding: var(--portal-space-3) var(--portal-space-4);
|
||||
border-bottom: 1px solid var(--portal-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ul-table th {
|
||||
background: var(--portal-surface-subtle);
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ul-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ul-table tbody tr:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--portal-primary) 5%,
|
||||
var(--portal-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.ul-table__numeric {
|
||||
font-family: var(--portal-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.ul-ticket-link {
|
||||
display: inline-flex;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
align-items: center;
|
||||
font-family: var(--portal-font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ul-status-badge {
|
||||
display: inline-flex;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
gap: var(--portal-space-2);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
padding: 0 var(--portal-space-3);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 750;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ul-status-badge__icon {
|
||||
font-family: var(--portal-font-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ul-status-badge--info {
|
||||
color: var(--portal-primary);
|
||||
}
|
||||
|
||||
.ul-status-badge--success {
|
||||
color: var(--portal-success);
|
||||
}
|
||||
|
||||
.ul-status-badge--warning {
|
||||
color: var(--portal-warning);
|
||||
}
|
||||
|
||||
.ul-status-badge--danger {
|
||||
color: var(--portal-danger);
|
||||
}
|
||||
|
||||
.ul-status-badge--neutral {
|
||||
color: var(--portal-text-muted);
|
||||
}
|
||||
|
||||
.ul-page-state {
|
||||
display: flex;
|
||||
min-height: 7rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--portal-space-3);
|
||||
border: 1px dashed var(--portal-border-strong);
|
||||
border-radius: var(--portal-radius-md);
|
||||
background: var(--portal-surface-subtle);
|
||||
padding: var(--portal-space-6);
|
||||
color: var(--portal-text-muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ul-page-state--error {
|
||||
border-style: solid;
|
||||
color: var(--portal-danger);
|
||||
}
|
||||
|
||||
.ul-page-state__icon {
|
||||
display: inline-grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
font-family: var(--portal-font-mono);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ul-page-state__detail {
|
||||
margin-top: var(--portal-space-1);
|
||||
}
|
||||
|
||||
.ul-detail-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, auto);
|
||||
gap: var(--portal-space-6);
|
||||
align-items: end;
|
||||
margin-bottom: var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-detail-header__title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--portal-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ul-detail-header__title {
|
||||
font-size: clamp(1.125rem, 2vw, 1.375rem);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.ul-breadcrumb {
|
||||
margin-bottom: var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-breadcrumb a {
|
||||
display: inline-flex;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ul-job-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--portal-space-4);
|
||||
margin-block: var(--portal-space-4) var(--portal-space-6);
|
||||
padding: var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-job-summary__description {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding-top: var(--portal-space-3);
|
||||
border-top: 1px solid var(--portal-border);
|
||||
}
|
||||
|
||||
.ul-metric {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--portal-space-1);
|
||||
}
|
||||
|
||||
.ul-metric__label {
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ul-metric__value {
|
||||
font-family: var(--portal-font-mono);
|
||||
font-size: 1.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ul-metric__detail {
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ul-live-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--portal-space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--portal-space-4);
|
||||
border: 1px solid var(--portal-border);
|
||||
border-radius: var(--portal-radius-md);
|
||||
background: var(--portal-surface);
|
||||
padding: var(--portal-space-3) var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-live-strip__latest {
|
||||
min-width: 12rem;
|
||||
flex: 1 1 auto;
|
||||
color: var(--portal-text-muted);
|
||||
font-family: var(--portal-font-mono);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ul-map-section {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--portal-border);
|
||||
border-radius: var(--portal-radius-md);
|
||||
background: var(--portal-map-field);
|
||||
box-shadow: var(--portal-shadow);
|
||||
}
|
||||
|
||||
.ul-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--portal-space-4);
|
||||
border-bottom: 1px solid var(--portal-border);
|
||||
background: var(--portal-surface);
|
||||
padding: var(--portal-space-3) var(--portal-space-4);
|
||||
}
|
||||
|
||||
.ul-section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.ul-map {
|
||||
position: relative;
|
||||
height: 30rem;
|
||||
overflow: hidden;
|
||||
background: var(--portal-map-field);
|
||||
}
|
||||
|
||||
.ul-map--compact {
|
||||
height: 20rem;
|
||||
}
|
||||
|
||||
.ul-map--detail {
|
||||
height: min(32.5rem, 65vh);
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.ul-map__canvas {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ul-map__fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--portal-space-6);
|
||||
}
|
||||
|
||||
.ul-map-popup-table {
|
||||
border-collapse: collapse;
|
||||
font-family: var(--portal-font-ui);
|
||||
}
|
||||
|
||||
.ul-map-popup-table td {
|
||||
padding-block: var(--portal-space-1);
|
||||
}
|
||||
|
||||
.ul-map-popup-table__label {
|
||||
padding-right: var(--portal-space-3);
|
||||
color: var(--portal-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ul-map-popup-table__value {
|
||||
font-family: var(--portal-font-mono);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.ul-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0 0 0 0) !important;
|
||||
clip-path: inset(50%) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
/* CSS custom properties are not valid in media conditions; values mirror the generated breakpoint tokens. */
|
||||
@media (max-width: 1200px) {
|
||||
.ul-shell__nav--desktop,
|
||||
.ul-shell__email {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ul-shell__menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ul-shell__header {
|
||||
align-items: flex-start;
|
||||
gap: var(--portal-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ul-shell__context {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ul-org-context {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ul-shell__account {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.ul-page-header,
|
||||
.ul-detail-header {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ul-page-header .ul-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ul-filter-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ul-table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ul-table,
|
||||
.ul-table tbody,
|
||||
.ul-table tr,
|
||||
.ul-table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ul-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ul-table tbody {
|
||||
display: grid;
|
||||
gap: var(--portal-space-3);
|
||||
}
|
||||
|
||||
.ul-table tbody tr {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--portal-border);
|
||||
border-radius: var(--portal-radius-md);
|
||||
background: var(--portal-surface);
|
||||
}
|
||||
|
||||
.ul-table td {
|
||||
display: grid;
|
||||
min-height: var(--ul-touch-minimum);
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--portal-space-3);
|
||||
border-bottom: 1px solid var(--portal-border);
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.ul-table td::before {
|
||||
content: attr(data-label);
|
||||
color: var(--portal-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ul-table td:first-child {
|
||||
background: var(--portal-surface-subtle);
|
||||
}
|
||||
|
||||
.ul-table td:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ul-job-summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.ul-map--detail {
|
||||
height: 28rem;
|
||||
min-height: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ul-shell__header {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.ul-shell__brand span:last-child,
|
||||
.ul-shell__account {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ul-org-context__role {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ul-shell__main {
|
||||
width: min(100% - 1.5rem, 75rem);
|
||||
}
|
||||
|
||||
.ul-page-header h1,
|
||||
.ul-detail-header h1 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ul-job-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ul-job-summary__description {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.ul-live-strip {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ul-map--detail {
|
||||
height: 24rem;
|
||||
min-height: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.ul-status-badge,
|
||||
.ul-page-state,
|
||||
.ul-panel,
|
||||
.ul-map-section {
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
}
|
||||
99
web/styles/tokens.css
Normal file
99
web/styles/tokens.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/* GENERATED FILE — DO NOT EDIT.
|
||||
* Source: meta/contracts/design-tokens.json v3.0.0
|
||||
* Run: node meta/scripts/generate-design-tokens.mjs
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-ul-theme="default"] {
|
||||
color-scheme: light;
|
||||
--ul-color-canvas: #EEF3F2;
|
||||
--ul-color-surface: #FCFEFD;
|
||||
--ul-color-surface-raised: #FFFFFF;
|
||||
--ul-color-surface-strong: #14252A;
|
||||
--ul-color-text: #14252A;
|
||||
--ul-color-text-muted: #5D6B6E;
|
||||
--ul-color-primary: #0B7774;
|
||||
--ul-color-primary-pressed: #075F5D;
|
||||
--ul-color-accent: #B82D46;
|
||||
--ul-color-border: #C7D2D0;
|
||||
--ul-color-focus: #B82D46;
|
||||
--ul-color-map: #DCE9E7;
|
||||
--ul-color-success: #347A55;
|
||||
--ul-color-warning: #A85C00;
|
||||
--ul-color-error: #B42318;
|
||||
--ul-color-neutral: #40565B;
|
||||
--ul-color-disabled: #819092;
|
||||
--ul-color-on-strong: #FFFFFF;
|
||||
--ul-color-on-primary: #FFFFFF;
|
||||
--ul-color-on-accent: #FFFFFF;
|
||||
--ul-space-2xs: 4px;
|
||||
--ul-space-xs: 8px;
|
||||
--ul-space-sm: 12px;
|
||||
--ul-space-md: 16px;
|
||||
--ul-space-lg: 24px;
|
||||
--ul-space-xl: 32px;
|
||||
--ul-space-2xl: 48px;
|
||||
--ul-radius-sm: 8px;
|
||||
--ul-radius-md: 12px;
|
||||
--ul-radius-lg: 20px;
|
||||
--ul-radius-pill: 999px;
|
||||
--ul-touch-minimum: 48px;
|
||||
--ul-touch-comfortable: 56px;
|
||||
--ul-touch-primary: 64px;
|
||||
--ul-type-family-sans: "IBM Plex Sans", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--ul-type-family-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--ul-type-display-size: 2.5rem;
|
||||
--ul-type-display-line-height: 1.2;
|
||||
--ul-type-display-weight: 700;
|
||||
--ul-type-page-size: 1.75rem;
|
||||
--ul-type-page-line-height: 1.2142857142857142;
|
||||
--ul-type-page-weight: 700;
|
||||
--ul-type-section-size: 1.375rem;
|
||||
--ul-type-section-line-height: 1.2727272727272727;
|
||||
--ul-type-section-weight: 600;
|
||||
--ul-type-body-size: 1rem;
|
||||
--ul-type-body-line-height: 1.5;
|
||||
--ul-type-body-weight: 400;
|
||||
--ul-type-body-sm-size: 0.875rem;
|
||||
--ul-type-body-sm-line-height: 1.4285714285714286;
|
||||
--ul-type-body-sm-weight: 400;
|
||||
--ul-type-label-size: 0.875rem;
|
||||
--ul-type-label-line-height: 1.4285714285714286;
|
||||
--ul-type-label-weight: 600;
|
||||
--ul-type-mono-size: 1rem;
|
||||
--ul-type-mono-line-height: 1.375;
|
||||
--ul-type-mono-weight: 500;
|
||||
--ul-motion-fast: 120ms;
|
||||
--ul-motion-base: 200ms;
|
||||
--ul-elevation-raised: 0 2px 8px rgba(20, 37, 42, 0.12);
|
||||
--ul-elevation-overlay: 0 6px 16px rgba(20, 37, 42, 0.16);
|
||||
--ul-breakpoint-compact: 600px;
|
||||
--ul-breakpoint-medium: 900px;
|
||||
--ul-breakpoint-wide: 1200px;
|
||||
}
|
||||
|
||||
|
||||
[data-ul-theme="sunlight"] {
|
||||
color-scheme: light;
|
||||
--ul-color-canvas: #FFFFFF;
|
||||
--ul-color-surface: #FFFFFF;
|
||||
--ul-color-surface-raised: #FFFFFF;
|
||||
--ul-color-surface-strong: #000000;
|
||||
--ul-color-text: #000000;
|
||||
--ul-color-text-muted: #344448;
|
||||
--ul-color-primary: #075F5D;
|
||||
--ul-color-primary-pressed: #14252A;
|
||||
--ul-color-accent: #982138;
|
||||
--ul-color-border: #5D6B6E;
|
||||
--ul-color-focus: #982138;
|
||||
--ul-color-map: #E8F0EF;
|
||||
--ul-color-success: #245F40;
|
||||
--ul-color-warning: #754000;
|
||||
--ul-color-error: #871A12;
|
||||
--ul-color-neutral: #14252A;
|
||||
--ul-color-disabled: #344448;
|
||||
--ul-color-on-strong: #FFFFFF;
|
||||
--ul-color-on-primary: #FFFFFF;
|
||||
--ul-color-on-accent: #FFFFFF;
|
||||
}
|
||||
|
||||
140
web/tests/s3-portal.test.mjs
Normal file
140
web/tests/s3-portal.test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const read = (path) => readFileSync(resolve(webRoot, path), "utf8");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function loadTsxComponent(path) {
|
||||
const typescript = require("typescript");
|
||||
const output = typescript.transpileModule(read(path), {
|
||||
compilerOptions: {
|
||||
esModuleInterop: true,
|
||||
jsx: typescript.JsxEmit.ReactJSX,
|
||||
module: typescript.ModuleKind.CommonJS,
|
||||
target: typescript.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function("require", "module", "exports", output)(
|
||||
require,
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
const migratedPresentation = [
|
||||
"components/Layout.tsx",
|
||||
"components/PortalUi.tsx",
|
||||
"pages/index.tsx",
|
||||
"pages/jobs/[jobId].tsx",
|
||||
];
|
||||
|
||||
test("migrated shell and job pages use reusable classes without inline presentation", () => {
|
||||
for (const path of migratedPresentation) {
|
||||
const source = read(path);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\bstyle\s*=/,
|
||||
`${path} must not use inline style props`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/#[\da-f]{3,8}\b/i,
|
||||
`${path} must not define visual color literals`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the authenticated shell keeps organization and navigation context accessible", () => {
|
||||
const layout = read("components/Layout.tsx");
|
||||
assert.match(layout, /Active organization/);
|
||||
assert.match(layout, /activeOrg\.role/);
|
||||
assert.match(layout, /aria-current=/);
|
||||
assert.match(layout, /Skip to main content/);
|
||||
assert.match(layout, /aria-label="Primary navigation"/);
|
||||
});
|
||||
|
||||
test("status presentation always combines a readable label with an icon", () => {
|
||||
const components = read("components/PortalUi.tsx");
|
||||
for (const status of [
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"COMPLETED",
|
||||
"CANCELLED",
|
||||
"LIVE",
|
||||
"OFFLINE",
|
||||
"ERROR",
|
||||
]) {
|
||||
assert.match(
|
||||
components,
|
||||
new RegExp(`${status}: \\{ icon: .+, label: .+, tone:`),
|
||||
);
|
||||
}
|
||||
assert.match(components, /ul-status-badge__icon/);
|
||||
assert.match(components, /aria-hidden="true"/);
|
||||
});
|
||||
|
||||
test("status and error-state components render accessible text and semantics", () => {
|
||||
const React = require("react");
|
||||
const { renderToStaticMarkup } = require("react-dom/server");
|
||||
const { PageState, StatusBadge } = loadTsxComponent(
|
||||
"components/PortalUi.tsx",
|
||||
);
|
||||
|
||||
const status = renderToStaticMarkup(
|
||||
React.createElement(StatusBadge, { status: "IN_PROGRESS" }),
|
||||
);
|
||||
assert.match(status, />In progress</);
|
||||
assert.match(status, /ul-status-badge--warning/);
|
||||
assert.match(status, /aria-hidden="true"/);
|
||||
|
||||
const error = renderToStaticMarkup(
|
||||
React.createElement(PageState, {
|
||||
kind: "error",
|
||||
title: "Jobs could not be loaded",
|
||||
}),
|
||||
);
|
||||
assert.match(error, /role="alert"/);
|
||||
assert.match(error, /Jobs could not be loaded/);
|
||||
});
|
||||
|
||||
test("responsive, focus, touch-target, and reduced-motion rules are present", () => {
|
||||
const css = read("styles/global.css");
|
||||
assert.ok(css.startsWith('@import "./tokens.css";'));
|
||||
for (const token of [
|
||||
"--ul-color-canvas",
|
||||
"--ul-color-primary",
|
||||
"--ul-color-map",
|
||||
"--ul-touch-minimum",
|
||||
"--ul-type-family-sans",
|
||||
]) {
|
||||
assert.match(css, new RegExp(`var\\(${token}\\)`));
|
||||
}
|
||||
assert.match(css, /:focus-visible/);
|
||||
assert.match(css, /min-height:\s*var\(--ul-touch-minimum\)/);
|
||||
assert.match(css, /@media \(max-width: 1200px\)/);
|
||||
assert.match(css, /@media \(max-width: 900px\)/);
|
||||
assert.match(css, /@media \(max-width: 600px\)/);
|
||||
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
|
||||
});
|
||||
|
||||
test("job pages preserve organization-scoped APIs, streaming, and provider-neutral map usage", () => {
|
||||
const jobs = read("pages/index.tsx");
|
||||
const detail = read("pages/jobs/[jobId].tsx");
|
||||
const dispatcher = read("components/map/JobMap.tsx");
|
||||
const stream = read("lib/use-job-stream.ts");
|
||||
|
||||
assert.match(jobs, /\/api\/orgs\/\$\{activeOrg\.org\.id\}\/jobs/);
|
||||
assert.match(detail, /\/api\/orgs\/\$\{orgId\}\/jobs\/\$\{jobId\}/);
|
||||
assert.match(detail, /<JobMap/);
|
||||
assert.doesNotMatch(detail, /EsriJobMap/);
|
||||
assert.match(dispatcher, /dynamic\(\(\) => import\('\.\/esri\/EsriJobMap'\)/);
|
||||
assert.match(stream, /channel: `job:\$\{jobId\}`/);
|
||||
assert.match(stream, /return connectionState/);
|
||||
});
|
||||
Reference in New Issue
Block a user