/** * Job list with filtering tabs. */ import React, { useEffect, useMemo, useState } from "react"; import { ArrowUpDown, LayoutGrid, Search, Table2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import type { Job, JobStatus } from "../../shared/types"; import { JobCard } from "./JobCard"; import { JobTable, type JobSort } from "./JobTable"; interface JobListProps { jobs: Job[]; onApply: (id: string) => void | Promise; onReject: (id: string) => void | Promise; onProcess: (id: string) => void | Promise; processingJobId: string | null; } type FilterTab = "ready" | "discovered" | "applied" | "all"; type ViewMode = "cards" | "table"; const JOB_LIST_VIEW_STORAGE_KEY = "jobops.jobs.viewMode"; const DEFAULT_SORT: JobSort = { key: "discoveredAt", direction: "desc" }; const sortLabels: Record = { discoveredAt: "Discovered", score: "Score", title: "Title", employer: "Company", source: "Source", location: "Location", status: "Status", }; const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ { id: "ready", label: "Ready", statuses: ["ready"] }, { id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] }, { id: "applied", label: "Applied", statuses: ["applied"] }, { id: "all", label: "All Jobs", statuses: [] }, ]; const emptyStateCopy: Record = { ready: "Run the pipeline to discover and process new jobs.", discovered: "All discovered jobs have been processed.", applied: "You haven't applied to any jobs yet.", all: "No jobs in the system yet. Run the pipeline to get started!", }; const statusRank: Record = { discovered: 0, processing: 1, ready: 2, applied: 3, rejected: 4, expired: 5, }; const dateValue = (value: string | null) => { if (!value) return null; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; }; const compareNullable = ( a: T | null | undefined, b: T | null | undefined, compare: (left: T, right: T) => number, ) => { const left = a ?? null; const right = b ?? null; if (left === null && right === null) return 0; if (left === null) return 1; if (right === null) return -1; return compare(left, right); }; const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); const compareNumber = (a: number, b: number) => a - b; const compareJobs = (a: Job, b: Job, sort: JobSort) => { let value = 0; switch (sort.key) { case "title": value = compareString(a.title, b.title); break; case "employer": value = compareString(a.employer, b.employer); break; case "source": value = compareString(a.source, b.source); break; case "location": value = compareNullable(a.location, b.location, compareString); break; case "status": value = statusRank[a.status] - statusRank[b.status]; break; case "score": const aScore = a.suitabilityScore; const bScore = b.suitabilityScore; if (aScore == null && bScore == null) { value = 0; break; } if (aScore == null) return 1; if (bScore == null) return -1; value = compareNumber(aScore, bScore); break; case "discoveredAt": value = compareNullable(dateValue(a.discoveredAt), dateValue(b.discoveredAt), compareNumber); break; default: value = 0; } if (value !== 0) return sort.direction === "asc" ? value : -value; const tieByDiscovered = compareNullable( dateValue(b.discoveredAt), dateValue(a.discoveredAt), compareNumber, ); if (tieByDiscovered !== 0) return tieByDiscovered; return a.id.localeCompare(b.id); }; const jobMatchesQuery = (job: Job, query: string) => { const normalized = query.trim().toLowerCase(); if (!normalized) return true; const haystack = [ job.title, job.employer, job.location, job.disciplines, job.salary, job.degreeRequired, job.starting, job.source, job.status, job.jobType, job.jobFunction, job.jobLevel, ] .filter(Boolean) .join(" ") .toLowerCase(); return haystack.includes(normalized); }; export const JobList: React.FC = ({ jobs, onApply, onReject, onProcess, processingJobId, }) => { const [activeTab, setActiveTab] = useState("ready"); const [searchQuery, setSearchQuery] = useState(""); const [sort, setSort] = useState(DEFAULT_SORT); const [selectedJobIds, setSelectedJobIds] = useState>(() => new Set()); const [batchAction, setBatchAction] = useState(null); const [viewMode, setViewMode] = useState(() => { try { const raw = localStorage.getItem(JOB_LIST_VIEW_STORAGE_KEY); if (raw === "cards" || raw === "table") return raw; return "cards"; } catch { return "cards"; } }); useEffect(() => { try { localStorage.setItem(JOB_LIST_VIEW_STORAGE_KEY, viewMode); } catch { // Ignore localStorage errors } }, [viewMode]); useEffect(() => { setSelectedJobIds(new Set()); }, [activeTab, viewMode]); const counts = useMemo(() => { const byTab: Record = { ready: 0, discovered: 0, applied: 0, all: jobs.length, }; for (const job of jobs) { if (job.status === "ready") byTab.ready += 1; if (job.status === "applied") byTab.applied += 1; if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1; } return byTab; }, [jobs]); const jobsForTab = useMemo(() => { const map = new Map(); for (const tab of tabs) { if (tab.statuses.length === 0) { map.set(tab.id, jobs); } else { map.set(tab.id, jobs.filter((job) => tab.statuses.includes(job.status))); } } return map; }, [jobs]); const visibleJobsForTab = useMemo(() => { const map = new Map(); const normalizedQuery = searchQuery.trim().toLowerCase(); for (const tab of tabs) { const base = jobsForTab.get(tab.id) ?? []; const filtered = normalizedQuery ? base.filter((job) => jobMatchesQuery(job, normalizedQuery)) : base; const sorted = [...filtered].sort((a, b) => compareJobs(a, b, sort)); map.set(tab.id, sorted); } return map; }, [jobsForTab, searchQuery, sort]); const activeTabJobs = visibleJobsForTab.get(activeTab) ?? []; useEffect(() => { setSelectedJobIds((current) => { const visibleIds = new Set(activeTabJobs.map((job) => job.id)); const next = new Set(); for (const id of current) { if (visibleIds.has(id)) next.add(id); } return next.size === current.size ? current : next; }); }, [activeTabJobs]); const activeResultsCount = visibleJobsForTab.get(activeTab)?.length ?? 0; const hasActiveFilters = searchQuery.trim().length > 0 || sort.key !== DEFAULT_SORT.key || sort.direction !== DEFAULT_SORT.direction; const selectedJobs = useMemo(() => { if (selectedJobIds.size === 0) return []; return activeTabJobs.filter((job) => selectedJobIds.has(job.id)); }, [activeTabJobs, selectedJobIds]); const selectedCount = selectedJobIds.size; const runBatch = async (action: "process" | "reject" | "apply") => { if (selectedJobs.length === 0) return; const eligible = selectedJobs.filter((job) => { if (action === "process") return job.status === "discovered"; if (action === "apply") return job.status === "ready"; return job.status === "discovered" || job.status === "ready"; }); const skipped = selectedJobs.length - eligible.length; if (eligible.length === 0) { toast.message("No eligible jobs selected"); return; } setBatchAction(action); try { for (const job of eligible) { if (action === "process") await Promise.resolve(onProcess(job.id)); if (action === "apply") await Promise.resolve(onApply(job.id)); if (action === "reject") await Promise.resolve(onReject(job.id)); } setSelectedJobIds(new Set()); const actionLabel = action === "process" ? "Processed" : action === "apply" ? "Applied" : "Skipped"; toast.success(`${actionLabel} ${eligible.length} jobs`, skipped > 0 ? { description: `Skipped ${skipped} ineligible.` } : undefined); } finally { setBatchAction(null); } }; return ( setActiveTab(value as FilterTab)} className="space-y-4" >
{tabs.map((tab) => ( {tab.label} ({counts[tab.id]}) ))}
setSearchQuery(event.target.value)} placeholder="Filter jobs..." className="pl-9" />
Sort by setSort({ key: value as JobSort["key"], direction: value === "score" || value === "discoveredAt" ? "desc" : "asc", }) } > {(Object.keys(sortLabels) as Array).map((key) => ( {sortLabels[key]} ))} setSort((current) => ({ ...current, direction: current.direction === "asc" ? "desc" : "asc", })) } > Direction: {sort.direction === "asc" ? "Ascending" : "Descending"} {activeResultsCount} jobs {hasActiveFilters && ( )}
{tabs.map((tab) => { const filteredJobs = visibleJobsForTab.get(tab.id) ?? []; const trimmedQuery = searchQuery.trim(); return ( {filteredJobs.length === 0 ? (
No jobs found

{trimmedQuery ? `No jobs match "${trimmedQuery}".` : emptyStateCopy[tab.id]}

) : ( <> {viewMode === "table" ? (
{tab.id === activeTab && selectedCount > 0 && (
{selectedCount}{" "} selected
)}
) : (
{filteredJobs.map((job) => ( ))}
)} )}
); })}
); };