initial table implementation

This commit is contained in:
DaKheera47 2025-12-15 00:50:49 +00:00
parent 68550952d4
commit 3056369a75
5 changed files with 703 additions and 42 deletions

View File

@ -2,14 +2,17 @@
* Job list with filtering tabs. * Job list with filtering tabs.
*/ */
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Loader2, RefreshCcw } from "lucide-react"; import { LayoutGrid, Loader2, RefreshCcw, Search, Table2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { Job, JobStatus } from "../../shared/types"; import type { Job, JobStatus } from "../../shared/types";
import { JobCard } from "./JobCard"; import { JobCard } from "./JobCard";
import { JobTable, type JobSort } from "./JobTable";
interface JobListProps { interface JobListProps {
jobs: Job[]; jobs: Job[];
@ -22,6 +25,10 @@ interface JobListProps {
} }
type FilterTab = "ready" | "discovered" | "applied" | "all"; 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[] }> = [ const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
{ id: "ready", label: "Ready", statuses: ["ready"] }, { id: "ready", label: "Ready", statuses: ["ready"] },
@ -37,6 +44,110 @@ const emptyStateCopy: Record<FilterTab, string> = {
all: "No jobs in the system yet. Run the pipeline to get started!", all: "No jobs in the system yet. Run the pipeline to get started!",
}; };
const statusRank: Record<JobStatus, number> = {
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 = <T,>(
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<JobListProps> = ({ export const JobList: React.FC<JobListProps> = ({
jobs, jobs,
onApply, onApply,
@ -47,6 +158,25 @@ export const JobList: React.FC<JobListProps> = ({
isProcessingAll, isProcessingAll,
}) => { }) => {
const [activeTab, setActiveTab] = useState<FilterTab>("ready"); const [activeTab, setActiveTab] = useState<FilterTab>("ready");
const [searchQuery, setSearchQuery] = useState("");
const [sort, setSort] = useState<JobSort>(DEFAULT_SORT);
const [viewMode, setViewMode] = useState<ViewMode>(() => {
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 counts = useMemo(() => {
const byTab: Record<FilterTab, number> = { const byTab: Record<FilterTab, number> = {
@ -79,43 +209,121 @@ export const JobList: React.FC<JobListProps> = ({
return map; return map;
}, [jobs]); }, [jobs]);
const visibleJobsForTab = useMemo(() => {
const map = new Map<FilterTab, Job[]>();
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 ( return (
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as FilterTab)} onValueChange={(value) => setActiveTab(value as FilterTab)}
className="space-y-4" className="space-y-4"
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="space-y-3">
<TabsList className="w-full sm:w-auto"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{tabs.map((tab) => ( <TabsList className="w-full sm:w-auto">
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none"> {tabs.map((tab) => (
{tab.label} <TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none">
<span className="ml-2 text-xs tabular-nums text-muted-foreground"> {tab.label}
({counts[tab.id]}) <span className="ml-2 text-xs tabular-nums text-muted-foreground">
</span> ({counts[tab.id]})
</TabsTrigger> </span>
))} </TabsTrigger>
</TabsList> ))}
</TabsList>
{activeTab === "discovered" && counts.discovered > 0 && ( <div className="flex items-center justify-between gap-2 sm:justify-end">
<Button onClick={onProcessAll} disabled={isProcessingAll} size="sm"> {activeTab === "discovered" && counts.discovered > 0 && (
{isProcessingAll ? ( <Button onClick={onProcessAll} disabled={isProcessingAll} size="sm">
<> {isProcessingAll ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <>
Processing... <Loader2 className="mr-2 h-4 w-4 animate-spin" />
</> Processing...
) : ( </>
<> ) : (
<RefreshCcw className="mr-2 h-4 w-4" /> <>
Process All ({counts.discovered}) <RefreshCcw className="mr-2 h-4 w-4" />
</> Process All ({counts.discovered})
</>
)}
</Button>
)} )}
</Button>
)} <div className="flex items-center rounded-md border bg-muted/20 p-0.5">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setViewMode("cards")}
aria-pressed={viewMode === "cards"}
className={cn("h-8 w-8", viewMode === "cards" && "bg-background shadow-sm")}
title="Card view"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setViewMode("table")}
aria-pressed={viewMode === "table"}
className={cn("h-8 w-8", viewMode === "table" && "bg-background shadow-sm")}
title="List view"
>
<Table2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Filter jobs..."
className="pl-9"
/>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<span className="text-sm tabular-nums text-muted-foreground">{activeResultsCount} jobs</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchQuery("");
setSort(DEFAULT_SORT);
}}
>
Reset filters
</Button>
)}
</div>
</div>
</div> </div>
{tabs.map((tab) => { {tabs.map((tab) => {
const filteredJobs = jobsForTab.get(tab.id) ?? []; const filteredJobs = visibleJobsForTab.get(tab.id) ?? [];
const trimmedQuery = searchQuery.trim();
return ( return (
<TabsContent key={tab.id} value={tab.id} className="space-y-4"> <TabsContent key={tab.id} value={tab.id} className="space-y-4">
@ -123,22 +331,42 @@ export const JobList: React.FC<JobListProps> = ({
<Card className="border-dashed bg-muted/20"> <Card className="border-dashed bg-muted/20">
<CardContent className="flex flex-col items-center justify-center gap-2 py-12 text-center"> <CardContent className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div> <div className="text-base font-semibold">No jobs found</div>
<p className="max-w-xl text-sm text-muted-foreground">{emptyStateCopy[tab.id]}</p> <p className="max-w-xl text-sm text-muted-foreground">
{trimmedQuery ? `No jobs match "${trimmedQuery}".` : emptyStateCopy[tab.id]}
</p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4"> <>
{filteredJobs.map((job) => ( {viewMode === "table" ? (
<JobCard <Card>
key={job.id} <CardContent className="p-0">
job={job} <JobTable
onApply={onApply} jobs={filteredJobs}
onReject={onReject} sort={sort}
onProcess={onProcess} onSortChange={setSort}
isProcessing={processingJobId === job.id} onApply={onApply}
/> onReject={onReject}
))} onProcess={onProcess}
</div> processingJobId={processingJobId}
/>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredJobs.map((job) => (
<JobCard
key={job.id}
job={job}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
isProcessing={processingJobId === job.id}
/>
))}
</div>
)}
</>
)} )}
</TabsContent> </TabsContent>
); );
@ -146,4 +374,3 @@ export const JobList: React.FC<JobListProps> = ({
</Tabs> </Tabs>
); );
}; };

View File

@ -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<Job["source"], string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
linkedin: "LinkedIn",
};
const defaultSortDirection: Record<JobSortKey, JobSortDirection> = {
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 (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (!isActive) {
onSortChange({ key: sortKey, direction: defaultSortDirection[sortKey] });
return;
}
onSortChange({
key: sortKey,
direction: sort.direction === "asc" ? "desc" : "asc",
});
}}
className={cn("h-8 w-full justify-start -mx-2 px-2 font-medium", className)}
>
{label}
<Icon className={cn("ml-1 h-3.5 w-3.5", !isActive && "opacity-60")} />
</Button>
);
};
export const JobTable: React.FC<JobTableProps> = ({
jobs,
sort,
onSortChange,
onApply,
onReject,
onProcess,
processingJobId,
}) => {
return (
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[28%]">
<SortButton label="Title" sortKey="title" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead className="w-[18%]">
<SortButton label="Company" sortKey="employer" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead>
<SortButton label="Source" sortKey="source" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead>
<SortButton label="Location" sortKey="location" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead>
<SortButton label="Status" sortKey="status" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead className="w-[10%] text-right">
<SortButton
label="Score"
sortKey="score"
sort={sort}
onSortChange={onSortChange}
className="justify-end"
/>
</TableHead>
<TableHead>
<SortButton
label="Discovered"
sortKey="discoveredAt"
sort={sort}
onSortChange={onSortChange}
/>
</TableHead>
<TableHead className="w-[1%] pr-3 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={job.id}>
<TableCell className="max-w-[520px]">
<Button
asChild
variant="link"
size="sm"
className="h-auto justify-start p-0 text-left leading-snug"
>
<a href={jobLink} target="_blank" rel="noopener noreferrer">
{job.title}
</a>
</Button>
</TableCell>
<TableCell className="max-w-[320px] truncate">
<span className="truncate">{job.employer}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="uppercase tracking-wide">
{sourceLabel[job.source]}
</Badge>
</TableCell>
<TableCell className="max-w-[260px] truncate text-muted-foreground">
{job.location || "—"}
</TableCell>
<TableCell>
<StatusBadge status={job.status} />
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{job.suitabilityScore ?? "—"}
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{formatDate(job.discoveredAt)}
</TableCell>
<TableCell className="pr-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open actions menu">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a href={jobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View Job
</a>
</DropdownMenuItem>
{hasPdf && (
<>
<DropdownMenuItem asChild>
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View PDF
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={pdfHref}
download={`resume_${safeFilenamePart(job.employer)}_${safeFilenamePart(job.title)}.pdf`}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</a>
</DropdownMenuItem>
</>
)}
{(canProcess || canReject || canApply) && <DropdownMenuSeparator />}
{canProcess && (
<DropdownMenuItem
onSelect={() => onProcess(job.id)}
disabled={isProcessing}
>
<RefreshCcw className="mr-2 h-4 w-4" />
{isProcessing ? "Processing..." : "Generate Resume"}
</DropdownMenuItem>
)}
{canReject && (
<DropdownMenuItem
onSelect={() => onReject(job.id)}
>
<XCircle className="mr-2 h-4 w-4" />
Skip
</DropdownMenuItem>
)}
{canApply && (
<DropdownMenuItem
onSelect={() => onApply(job.id)}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Mark Applied
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};

View File

@ -3,5 +3,6 @@ export { Stats } from './Stats';
export { StatusBadge } from './StatusBadge'; export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator'; export { ScoreIndicator } from './ScoreIndicator';
export { JobCard } from './JobCard'; export { JobCard } from './JobCard';
export { JobTable } from './JobTable';
export { JobList } from './JobList'; export { JobList } from './JobList';
export { PipelineProgress } from './PipelineProgress'; export { PipelineProgress } from './PipelineProgress';

View File

@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,113 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}