feat(sprint-3): refresh responsive UlHub portal
This commit is contained in:
@@ -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>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
<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}>
|
||||
<td data-label="Ticket">
|
||||
<Link className="ul-ticket-link" href={`/jobs/${job.id}`}>
|
||||
{job.ticketNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<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>
|
||||
</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>
|
||||
</td>
|
||||
<td>{job.title}</td>
|
||||
<td>{job.address ?? '—'}</td>
|
||||
<td>
|
||||
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
|
||||
</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>
|
||||
)}
|
||||
</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…" />;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user