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,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>&nbsp; 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>
);
}