Replaces the device-events demo with the actual product:
- Prisma + PostGIS data layer (postgis/postgis:17-3.5). Lat/lng decimals
are the source of truth; a generated geometry(Point,4326) column with
a GIST index backs bbox queries. Migrations apply on container boot.
- JWT auth (bcryptjs + httpOnly cookie) with public registration that
creates an org; per-org roles (ORG_ADMIN/MEMBER/VIEWER) enforced by
guards on all /orgs/:orgId routes.
- Scoped API keys (X-API-Key, sha256-hashed, shown once) for
programmatic access, manageable by org admins.
- REST API: jobs/tickets CRUD with filters, points query (time range,
recordedAt cursor, bbox), members, devices, api-keys.
- MQTT ingest: devices publish to devices/{username}/points and /jobs;
unknown tickets auto-create stub jobs (source=DEVICE); every message
is raw-logged to device_events; acks on devices/{username}/jobs/ack.
Broker gets a dedicated backend user; testuser is now a plain device.
- Realtime: plain-WS gateway at /api/ws (socket.io removed) with
cookie auth and per-job channels feeding the map live.
- Next.js frontend: login/register, jobs list with filters, job detail
with live Google map (APWA utility colors, polylines per run) behind
a provider-neutral JobMap abstraction for a future Esri swap, and
settings pages for members/devices/api-keys.
- Seed: Umagul org, admin user, testuser device, demo job with RTK
points. Sample publisher updated to the new topic contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
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';
|
|
|
|
interface JobRow {
|
|
id: string;
|
|
ticketNumber: string;
|
|
title: string;
|
|
address: string | null;
|
|
status: string;
|
|
source: string;
|
|
createdAt: string;
|
|
assignedTo: { id: string; name: string } | null;
|
|
_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',
|
|
};
|
|
|
|
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 [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!activeOrg) {
|
|
return;
|
|
}
|
|
const params = new URLSearchParams();
|
|
if (status) params.set('status', status);
|
|
if (q) params.set('q', q);
|
|
api
|
|
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`)
|
|
.then((res) => {
|
|
setJobs(res.jobs);
|
|
setTotal(res.total);
|
|
setError(null);
|
|
})
|
|
.catch((err) => setError(err.message));
|
|
}, [activeOrg, status, q]);
|
|
|
|
if (loading || !user) {
|
|
return null;
|
|
}
|
|
|
|
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'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Link href="/jobs/new">
|
|
<button>+ New job</button>
|
|
</Link>
|
|
</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>
|
|
);
|
|
}
|