Files
ulweb/web/pages/jobs/[jobId].tsx
ulhub 8c5de40c5d Add live transmitter position (status) alongside logged points
devices/<serial>/log messages now carry a "type": "log" or "status".
"log" persists a LocatePoint as before; "status" carries the same
position + telemetry shape but is broadcast live over the job's
realtime channel without touching locate_points — it's a current-
position update, not a recorded point.

The job map renders the latest status as a blue "you are here" marker
(with a soft accuracy-radius halo) that moves in place as new updates
arrive, separate from the colored polyline of logged points. Clicking
it shows the same detail readout as a logged point.

The simulator gained a message-type toggle (Log point / Status
update) so both paths can be exercised from /sim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 14:52:53 +00:00

127 lines
4.4 KiB
TypeScript

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';
interface JobDetail {
id: string;
ticketNumber: string;
title: string;
description: string | null;
address: string | null;
status: string;
source: string;
dueAt: string | null;
createdAt: string;
assignedTo: { id: string; name: string; email: string } | null;
_count: { points: number };
}
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 orgId = activeOrg?.org.id ?? null;
const [job, setJob] = useState<JobDetail | null>(null);
const [points, setPoints] = useState<MapPoint[]>([]);
const [live, setLive] = useState(0);
const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null);
const [error, setError] = useState<string | null>(null);
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));
}, [orgId, jobId]);
const onLivePoints = useCallback((incoming: MapPoint[]) => {
setPoints((prev) => {
const seen = new Set(prev.map((p) => p.id));
const fresh = incoming.filter((p) => !seen.has(p.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
setLive((n) => n + incoming.length);
}, []);
const onStatus = useCallback((status: LiveStatus) => setLiveStatus(status), []);
useJobStream(jobId, onLivePoints, onStatus);
const updateStatus = async (status: string) => {
if (!orgId || !jobId) {
return;
}
try {
setJob(await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, { status }));
} catch (err: any) {
setError(err.message);
}
};
if (loading || !user) {
return null;
}
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>}
<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>
)}
{liveStatus && (
<span style={{ color: '#1a73e8' }}>
transmitter {liveStatus.serial} live ({new Date(liveStatus.recordedAt).toLocaleTimeString()})
</span>
)}
</div>
<JobMap points={points} liveStatus={liveStatus} heightPx={520} />
</>
)}
</Layout>
);
}