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(null); const [points, setPoints] = useState([]); const [live, setLive] = useState(0); const [liveStatus, setLiveStatus] = useState(null); const [error, setError] = useState(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(`/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(`/api/orgs/${orgId}/jobs/${jobId}`, { status, }), ); setError(null); } catch (err: any) { setError(err.message); } finally { setUpdatingStatus(false); } }; if (loading || !user) { return ; } 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 ( {error && !job ? ( {error} ) : fetching || !job ? ( ) : ( <>
Locate job

{job.ticketNumber}

{job.title}

{job.address ?? "No address"} · {humanizeStatus(job.source)}{" "} source

{error && ( {error} )}
} /> {job.description && (

{job.description}

)}
{points.length > 0 && ( Latest point: {pointSummary(points[points.length - 1])} )} {liveStatus && ( )}
Live operations

Locate map

)}
); }