Files
linkder/apps/web/src/app/jobs-panel.tsx
T
serfaandClaude Opus 5 974e312534 M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
Closes the funnel. Before this the product could match two people and then
stopped: `quotes`, `bookings` and `reviews` had tables and state machines and
nothing that wrote a row, the entry deck's right swipe was wired to an empty
handler, and every address resolved to the city centre.

Jobs tab and chat
- message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not
  a job — one job with three interested pros is three private conversations.
- Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the
  pros who accepted, and the conversation itself with attachments.

Hiring from the deck
- A right swipe on the entry deck opened nothing. It now resolves "which job?"
  through a sheet — sign in, pick an open job, or post one — and calls the same
  deck.swipe the per-job deck does, so the open-request cap and row lock apply
  exactly once. Swipes are vetoable so closing the sheet returns the card.

Geocoding
- ST_Distance and ST_DWithin rank and filter every deck, and both operands were
  placeholders. Addresses now resolve through Mapbox (permanent=true, which is
  what licenses storing the coordinates), the server resolves points rather than
  trusting client-supplied lat/lng, and every stored point records how it was
  obtained. A `city`-precision base cannot reach the verification queue.

Quote -> booking -> review
- The commercial chain, minus payments. Accepting a quote is the only place a
  booking is created; confirming completion is what unlocks reviews and moves
  the pro's completed_jobs.
- Reviews publish double-blind with no sweeper: each is written with
  published_at already set to its embargo deadline and every read filters
  published_at <= now(), so it publishes itself. The second review pulls both
  forward. A silent counterparty cannot bury a bad review by never replying.

State machine changes, both deliberate
- booked -> matched: a cancelled booking is not a cancelled job.
- scheduled -> awaiting_confirmation: in_progress is optional, so a pro who
  never tapped Start can still say the work is done.

Test suite
- api tests ran files in parallel against one database and failed roughly one
  run in three on whichever file lost the race. Serialised, and three fixtures
  that grabbed "the first client" pinned to the seeded accounts.

Also includes work from a parallel session: admin verification queue, pro
public profile and reviews read path, notification sending, denormalised stats
recompute, search, and observability.

318 tests passing; typecheck and lint clean across 7 packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 06:29:59 -04:00

357 lines
11 KiB
TypeScript

'use client';
import { useState } from 'react';
import Link from 'next/link';
import { api } from '@/lib/trpc';
import { SignedOut } from '@/components/chrome/signed-out';
import { buttonClasses, EmptyState, Segmented } from '@/components/ui';
import { JobRow, JobRowSkeleton, type JobRowData, type Perspective } from '@/components/jobs/job-row';
import { JobDetail } from '@/components/jobs/job-detail';
import { ChatThread } from '@/components/jobs/chat-thread';
import { ReviewSheet } from '@/components/jobs/review-sheet';
import { ReviewPrompt } from '@/components/jobs/review-prompt';
/**
* Where you are inside the tab.
*
* `jobId` on a thread is where Back goes, not where the thread came from: a
* client reaches a conversation through their job and should land back on it,
* while a pro's list row IS the conversation and has no middle screen to return
* to. Storing the destination rather than the history keeps Back from ever
* needing a stack.
*/
export type JobsView =
| { kind: 'list' }
| { kind: 'job'; jobId: string }
| { kind: 'thread'; matchId: string; jobId: string | null };
export type JobsSegment = 'current' | 'past';
export interface JobsState {
view: JobsView;
segment: JobsSegment;
}
export const INITIAL_JOBS_STATE: JobsState = { view: { kind: 'list' }, segment: 'current' };
/**
* The Jobs tab.
*
* Current and past are one list under a segmented control rather than two tabs,
* because they are the same objects at different points in their life — and
* because a job moves between them on its own, without the user doing anything.
* `isActive` comes from the server (ACTIVE_JOB_STATUSES), so the split cannot
* drift from the state machine.
*
* State is owned by the parent for the reason SearchPanel's is: the tab bar
* unmounts panels on switch, and losing your place in a half-read conversation
* because you glanced at Settings is worse than losing a search query.
*/
export function JobsPanel({
state,
onChange,
}: {
state: JobsState;
onChange: (next: JobsState) => void;
}) {
const me = api.user.me.useQuery(undefined, { retry: false });
const go = (view: JobsView) => onChange({ ...state, view });
if (me.isLoading) {
return (
<Shell>
<div className="flex flex-col gap-3">
<JobRowSkeleton />
<JobRowSkeleton />
<JobRowSkeleton />
</div>
</Shell>
);
}
if (me.error || !me.data) {
return (
<SignedOut
title="Sign in to see your jobs"
body="Everything you have posted, and every conversation about it."
/>
);
}
if (state.view.kind === 'thread') {
const { matchId, jobId } = state.view;
return (
<ChatThread
matchId={matchId}
onBack={() => go(jobId ? { kind: 'job', jobId } : { kind: 'list' })}
/>
);
}
if (state.view.kind === 'job') {
const { jobId } = state.view;
return (
<JobDetail
jobId={jobId}
onBack={() => go({ kind: 'list' })}
onOpenThread={(matchId) => go({ kind: 'thread', matchId, jobId })}
/>
);
}
return me.data.role === 'pro' ? (
<ProJobs state={state} onChange={onChange} />
) : (
<ClientJobs state={state} onChange={onChange} />
);
}
/**
* Work that is finished and still owed a review, above the Past list.
*
* The reason the Past segment is worth opening. A rating is the only thing a new
* pro has to trade on, and nobody navigates to a finished job to leave one
* unprompted — so it is asked for where the finished job already is.
*/
function PendingReviews() {
const pending = api.review.pending.useQuery(undefined, { retry: false });
const [open, setOpen] = useState<{
bookingId: string;
subjectName: string;
jobTitle: string;
} | null>(null);
const rows = pending.data ?? [];
if (rows.length === 0) return null;
return (
<>
<ul className="mb-4 flex flex-col gap-2">
{rows.map((r) => (
<li key={r.bookingId}>
<ReviewPrompt
subjectName={r.subjectName}
jobTitle={r.jobTitle}
onOpen={() =>
setOpen({
bookingId: r.bookingId,
subjectName: r.subjectName,
jobTitle: r.jobTitle,
})
}
/>
</li>
))}
</ul>
<ReviewSheet
bookingId={open?.bookingId ?? null}
subjectName={open?.subjectName ?? ''}
jobTitle={open?.jobTitle ?? ''}
open={open !== null}
onClose={() => setOpen(null)}
/>
</>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>;
}
/* ─────────────────────────────── client ─────────────────────────────── */
function ClientJobs({
state,
onChange,
}: {
state: JobsState;
onChange: (next: JobsState) => void;
}) {
// Polled, not pushed: a job list that goes stale hides the one thing this tab
// exists to surface — that somebody replied. Thirty seconds is cheap here and
// the thread itself refreshes far faster while it is open.
const jobs = api.job.mine.useQuery(undefined, { refetchInterval: 30_000 });
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
id: j.id,
title: j.title,
status: j.status,
subtitle: j.categoryName,
createdAt: j.createdAt,
matchCount: j.matchCount,
pendingCount: j.pendingCount,
unreadCount: j.unreadCount,
lastMessageAt: j.lastMessageAt,
nextBookingAt: j.nextBookingAt,
}));
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
return (
<JobList
perspective="client"
loading={jobs.isLoading}
rows={rows}
activeIds={new Set(active)}
state={state}
onChange={onChange}
onOpen={(jobId) => onChange({ ...state, view: { kind: 'job', jobId } })}
empty={{
current: {
title: 'No jobs on the go',
body: 'Post a job and we will build you a deck of verified pros who cover your area.',
action: (
<Link href="/jobs/new" className={buttonClasses({ variant: 'primary', size: 'md' })}>
Post a job
</Link>
),
},
past: {
title: 'Nothing finished yet',
body: 'Jobs you complete or cancel move here, with the conversation kept as a record.',
},
}}
action={
<Link
href="/jobs/new"
className={buttonClasses({ variant: 'primary', size: 'md', block: true })}
>
Post a job
</Link>
}
/>
);
}
/* ───────────────────────────────── pro ──────────────────────────────── */
function ProJobs({ state, onChange }: { state: JobsState; onChange: (next: JobsState) => void }) {
const jobs = api.job.mineForPro.useQuery(undefined, { refetchInterval: 30_000 });
const byId = new Map((jobs.data ?? []).map((j) => [j.id, j]));
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
id: j.id,
title: j.title,
status: j.status,
// The customer, not the trade — a plumber's list of plumbing jobs does not
// need to say "Plumber" eleven times.
subtitle: j.clientName ?? 'Customer',
createdAt: j.createdAt,
unreadCount: j.unreadCount,
lastMessageAt: j.lastMessageAt,
nextBookingAt: j.nextBookingAt,
}));
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
return (
<JobList
perspective="pro"
loading={jobs.isLoading}
rows={rows}
activeIds={new Set(active)}
state={state}
onChange={onChange}
// A pro's row is one match, so it opens the conversation directly. There
// is no middle screen listing "the pros on this job" — that is them.
onOpen={(jobId) => {
const match = byId.get(jobId);
if (!match) return;
onChange({ ...state, view: { kind: 'thread', matchId: match.matchId, jobId: null } });
}}
empty={{
current: {
title: 'No live jobs',
body: 'Jobs you accept from your inbox appear here, with the customer chat attached.',
},
past: {
title: 'No finished jobs yet',
body: 'Work you complete moves here, and the customer can leave you a review.',
},
}}
/>
);
}
/* ─────────────────────────────── shared ─────────────────────────────── */
interface EmptyCopy {
title: string;
body: string;
action?: React.ReactNode;
}
function JobList({
perspective,
loading,
rows,
activeIds,
state,
onChange,
onOpen,
empty,
action,
}: {
perspective: Perspective;
loading: boolean;
rows: JobRowData[];
activeIds: Set<string>;
state: JobsState;
onChange: (next: JobsState) => void;
onOpen: (jobId: string) => void;
empty: Record<JobsSegment, EmptyCopy>;
action?: React.ReactNode;
}) {
const current = rows.filter((r) => activeIds.has(r.id));
const past = rows.filter((r) => !activeIds.has(r.id));
const shown = state.segment === 'current' ? current : past;
const copy = empty[state.segment];
return (
<Shell>
<h1 className="mb-4 text-h2">Jobs</h1>
{/* Counts are rendered even at zero — a missing number reads as loading. */}
<Segmented
label="Job list view"
value={state.segment}
onChange={(segment) => onChange({ ...state, segment })}
segments={[
{ id: 'current', label: 'Current', count: current.length },
{ id: 'past', label: 'Past', count: past.length },
]}
/>
{/* Past only: a review belongs beside finished work, not beside a job
somebody is still waiting on. */}
{state.segment === 'past' && (
<div className="mt-5">
<PendingReviews />
</div>
)}
{loading ? (
<div className="mt-5 flex flex-col gap-3">
<JobRowSkeleton />
<JobRowSkeleton />
<JobRowSkeleton />
</div>
) : shown.length === 0 ? (
<EmptyState className="mt-5" title={copy.title} body={copy.body} action={copy.action} />
) : (
<>
{action && <div className="mt-5">{action}</div>}
<ul className="mt-3 flex flex-col gap-3">
{shown.map((job) => (
<li key={job.id}>
<JobRow job={job} perspective={perspective} onOpen={onOpen} />
</li>
))}
</ul>
</>
)}
</Shell>
);
}