feat(sprint-3): refresh responsive UlHub portal

This commit is contained in:
Brent Perteet
2026-08-20 22:00:38 -05:00
parent e552ae4e5c
commit d0b2f28762
12 changed files with 1710 additions and 228 deletions

View File

@@ -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…" />;
}
return (
<Layout title={job ? job.ticketNumber : 'Job'}>
{error && <p style={{ color: '#c62828' }}>{error}</p>}
{job && (
<>
<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}
</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>}
const streamStatus = streamState.toUpperCase();
const streamLabel =
streamState === "live"
? live > 0
? `Live · ${live} new ${live === 1 ? "point" : "points"} this session`
: "Live · waiting for activity"
: undefined;
<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>
{points.length > 0 && (
<span style={{ color: '#777' }}>latest: {pointSummary(points[points.length - 1])}</span>
return (
<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…" />
) : (
<>
<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>
<span
id="job-status-update"
className="ul-visually-hidden"
aria-live="polite"
>
{updatingStatus
? "Updating job status"
: `Current status: ${humanizeStatus(job.status)}`}
</span>
</label>
</header>
{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>
)}
{liveStatus && (
<span style={{ color: '#1a73e8' }}>
transmitter {liveStatus.serial} live ({new Date(liveStatus.recordedAt).toLocaleTimeString()})
</section>
<div className="ul-live-strip" aria-live="polite">
<StatusBadge status={streamStatus} label={streamLabel} />
{points.length > 0 && (
<span className="ul-live-strip__latest">
Latest point: {pointSummary(points[points.length - 1])}
</span>
)}
{liveStatus && (
<StatusBadge
status="LIVE"
label={`Transmitter ${liveStatus.serial} · ${new Date(liveStatus.recordedAt).toLocaleTimeString()}`}
/>
)}
</div>
<JobMap points={points} liveStatus={liveStatus} heightPx={520} />
<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>