diff --git a/orchestrator/src/client/components/JobList.tsx b/orchestrator/src/client/components/JobList.tsx index 9e48c97..866173d 100644 --- a/orchestrator/src/client/components/JobList.tsx +++ b/orchestrator/src/client/components/JobList.tsx @@ -2,14 +2,17 @@ * Job list with filtering tabs. */ -import React, { useMemo, useState } from "react"; -import { Loader2, RefreshCcw } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { LayoutGrid, Loader2, RefreshCcw, Search, Table2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +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[]; @@ -22,6 +25,10 @@ interface JobListProps { } 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 tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [ { id: "ready", label: "Ready", statuses: ["ready"] }, @@ -37,6 +44,110 @@ const emptyStateCopy: Record = { 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": + if (a.suitabilityScore == null && b.suitabilityScore == null) { + value = 0; + break; + } + if (a.suitabilityScore == null) return 1; + if (b.suitabilityScore == null) return -1; + value = compareNumber(a.suitabilityScore, b.suitabilityScore); + 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, @@ -47,6 +158,25 @@ export const JobList: React.FC = ({ isProcessingAll, }) => { const [activeTab, setActiveTab] = useState("ready"); + const [searchQuery, setSearchQuery] = useState(""); + const [sort, setSort] = useState(DEFAULT_SORT); + 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]); const counts = useMemo(() => { const byTab: Record = { @@ -79,43 +209,121 @@ export const JobList: React.FC = ({ 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 activeResultsCount = visibleJobsForTab.get(activeTab)?.length ?? 0; + const hasActiveFilters = + searchQuery.trim().length > 0 || + sort.key !== DEFAULT_SORT.key || + sort.direction !== DEFAULT_SORT.direction; + return ( setActiveTab(value as FilterTab)} className="space-y-4" > -
- - {tabs.map((tab) => ( - - {tab.label} - - ({counts[tab.id]}) - - - ))} - +
+
+ + {tabs.map((tab) => ( + + {tab.label} + + ({counts[tab.id]}) + + + ))} + - {activeTab === "discovered" && counts.discovered > 0 && ( - )} - - )} + +
+ + +
+
+
+ +
+
+ + setSearchQuery(event.target.value)} + placeholder="Filter jobs..." + className="pl-9" + /> +
+ +
+ {activeResultsCount} jobs + {hasActiveFilters && ( + + )} +
+
{tabs.map((tab) => { - const filteredJobs = jobsForTab.get(tab.id) ?? []; + const filteredJobs = visibleJobsForTab.get(tab.id) ?? []; + const trimmedQuery = searchQuery.trim(); return ( @@ -123,22 +331,42 @@ export const JobList: React.FC = ({
No jobs found
-

{emptyStateCopy[tab.id]}

+

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

) : ( -
- {filteredJobs.map((job) => ( - - ))} -
+ <> + {viewMode === "table" ? ( + + + + + + ) : ( +
+ {filteredJobs.map((job) => ( + + ))} +
+ )} + )}
); @@ -146,4 +374,3 @@ export const JobList: React.FC = ({
); }; - diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx new file mode 100644 index 0000000..3e05a83 --- /dev/null +++ b/orchestrator/src/client/components/JobTable.tsx @@ -0,0 +1,297 @@ +/** + * Table-based job list view. + */ + +import React from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + CheckCircle2, + Download, + ExternalLink, + MoreHorizontal, + RefreshCcw, + XCircle, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import type { Job } from "../../shared/types"; +import { StatusBadge } from "./StatusBadge"; + +export type JobSortKey = + | "title" + | "employer" + | "source" + | "location" + | "status" + | "score" + | "discoveredAt"; + +export type JobSortDirection = "asc" | "desc"; + +export interface JobSort { + key: JobSortKey; + direction: JobSortDirection; +} + +export interface JobTableProps { + jobs: Job[]; + sort: JobSort; + onSortChange: (sort: JobSort) => void; + onApply: (id: string) => void; + onReject: (id: string) => void; + onProcess: (id: string) => void; + processingJobId: string | null; +} + +const sourceLabel: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", +}; + +const defaultSortDirection: Record = { + title: "asc", + employer: "asc", + source: "asc", + location: "asc", + status: "asc", + score: "desc", + discoveredAt: "desc", +}; + +const formatDate = (dateStr: string | null) => { + if (!dateStr) return null; + try { + return new Date(dateStr).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + } catch { + return dateStr; + } +}; + +const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_"); + +const SortButton: React.FC<{ + label: string; + sortKey: JobSortKey; + sort: JobSort; + onSortChange: (sort: JobSort) => void; + className?: string; +}> = ({ label, sortKey, sort, onSortChange, className }) => { + const isActive = sort.key === sortKey; + const Icon = isActive ? (sort.direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; + + return ( + + ); +}; + +export const JobTable: React.FC = ({ + jobs, + sort, + onSortChange, + onApply, + onReject, + onProcess, + processingJobId, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + Actions + + + + + {jobs.map((job) => { + const jobLink = job.applicationLink || job.jobUrl; + const hasPdf = !!job.pdfPath; + const pdfHref = `/pdfs/resume_${job.id}.pdf`; + + const canApply = job.status === "ready"; + const canProcess = job.status === "discovered"; + const canReject = ["discovered", "ready"].includes(job.status); + const isProcessing = processingJobId === job.id; + + return ( + + + + + + + {job.employer} + + + + + {sourceLabel[job.source]} + + + + + {job.location || "—"} + + + + + + + + {job.suitabilityScore ?? "—"} + + + + {formatDate(job.discoveredAt)} + + + + + + + + + + + + + View Job + + + + {hasPdf && ( + <> + + + + View PDF + + + + + + Download PDF + + + + )} + + {(canProcess || canReject || canApply) && } + + {canProcess && ( + onProcess(job.id)} + disabled={isProcessing} + > + + {isProcessing ? "Processing..." : "Generate Resume"} + + )} + + {canReject && ( + onReject(job.id)} + > + + Skip + + )} + + {canApply && ( + onApply(job.id)} + > + + Mark Applied + + )} + + + + + ); + })} + +
+ ); +}; diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index b8adf8b..b5baf1a 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -3,5 +3,6 @@ export { Stats } from './Stats'; export { StatusBadge } from './StatusBadge'; export { ScoreIndicator } from './ScoreIndicator'; export { JobCard } from './JobCard'; +export { JobTable } from './JobTable'; export { JobList } from './JobList'; export { PipelineProgress } from './PipelineProgress'; diff --git a/orchestrator/src/components/ui/input.tsx b/orchestrator/src/components/ui/input.tsx new file mode 100644 index 0000000..04aefeb --- /dev/null +++ b/orchestrator/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } + diff --git a/orchestrator/src/components/ui/table.tsx b/orchestrator/src/components/ui/table.tsx new file mode 100644 index 0000000..84287ad --- /dev/null +++ b/orchestrator/src/components/ui/table.tsx @@ -0,0 +1,113 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", className)} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} +