'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 (
); } if (me.error || !me.data) { return ( ); } if (state.view.kind === 'thread') { const { matchId, jobId } = state.view; return ( go(jobId ? { kind: 'job', jobId } : { kind: 'list' })} /> ); } if (state.view.kind === 'job') { const { jobId } = state.view; return ( go({ kind: 'list' })} onOpenThread={(matchId) => go({ kind: 'thread', matchId, jobId })} /> ); } return me.data.role === 'pro' ? ( ) : ( ); } /** * 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 ( <>
    {rows.map((r) => (
  • setOpen({ bookingId: r.bookingId, subjectName: r.subjectName, jobTitle: r.jobTitle, }) } />
  • ))}
setOpen(null)} /> ); } function Shell({ children }: { children: React.ReactNode }) { return
{children}
; } /* ─────────────────────────────── 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 ( 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: ( Post a job ), }, past: { title: 'Nothing finished yet', body: 'Jobs you complete or cancel move here, with the conversation kept as a record.', }, }} action={ Post a job } /> ); } /* ───────────────────────────────── 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 ( { 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; state: JobsState; onChange: (next: JobsState) => void; onOpen: (jobId: string) => void; empty: Record; 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 (

Jobs

{/* Counts are rendered even at zero — a missing number reads as loading. */} 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' && (
)} {loading ? (
) : shown.length === 0 ? ( ) : ( <> {action &&
{action}
}
    {shown.map((job) => (
  • ))}
)}
); }