256 lines
7.9 KiB
TypeScript
256 lines
7.9 KiB
TypeScript
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;
|
|
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);
|
|
const [fetching, setFetching] = useState(false);
|
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!orgId || !jobId) {
|
|
return;
|
|
}
|
|
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[]) => {
|
|
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),
|
|
[],
|
|
);
|
|
|
|
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,
|
|
}),
|
|
);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setUpdatingStatus(false);
|
|
}
|
|
};
|
|
|
|
if (loading || !user) {
|
|
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"}>
|
|
<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>
|
|
)}
|
|
</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>
|
|
|
|
<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>
|
|
);
|
|
}
|