529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
onReject: (id: string) => void | Promise<void>;
|
|
onProcess: (id: string) => void | Promise<void>;
|
|
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<JobSort["key"], string> = {
|
|
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<FilterTab, string> = {
|
|
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<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":
|
|
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<JobListProps> = ({
|
|
jobs,
|
|
onApply,
|
|
onReject,
|
|
onProcess,
|
|
processingJobId,
|
|
}) => {
|
|
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [sort, setSort] = useState<JobSort>(DEFAULT_SORT);
|
|
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(() => new Set());
|
|
const [batchAction, setBatchAction] = useState<null | "process" | "reject" | "apply">(null);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
setSelectedJobIds(new Set());
|
|
}, [activeTab, viewMode]);
|
|
|
|
const counts = useMemo(() => {
|
|
const byTab: Record<FilterTab, number> = {
|
|
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<FilterTab, Job[]>();
|
|
|
|
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<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 activeTabJobs = visibleJobsForTab.get(activeTab) ?? [];
|
|
|
|
useEffect(() => {
|
|
setSelectedJobIds((current) => {
|
|
const visibleIds = new Set(activeTabJobs.map((job) => job.id));
|
|
const next = new Set<string>();
|
|
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 (
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) => setActiveTab(value as FilterTab)}
|
|
className="space-y-4"
|
|
>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<TabsList className="w-full sm:w-auto">
|
|
{tabs.map((tab) => (
|
|
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none">
|
|
{tab.label}
|
|
<span className="ml-2 text-xs tabular-nums text-muted-foreground">
|
|
({counts[tab.id]})
|
|
</span>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
|
|
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
|
<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 flex-wrap items-center gap-2 self-start sm:self-auto">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<ArrowUpDown className="h-4 w-4" />
|
|
<span className="hidden sm:inline">
|
|
Sort: {sortLabels[sort.key]} {sort.direction === "asc" ? "↑" : "↓"}
|
|
</span>
|
|
<span className="sm:hidden">Sort</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuRadioGroup
|
|
value={sort.key}
|
|
onValueChange={(value) =>
|
|
setSort({
|
|
key: value as JobSort["key"],
|
|
direction:
|
|
value === "score" || value === "discoveredAt"
|
|
? "desc"
|
|
: "asc",
|
|
})
|
|
}
|
|
>
|
|
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
|
|
<DropdownMenuRadioItem key={key} value={key}>
|
|
{sortLabels[key]}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
setSort((current) => ({
|
|
...current,
|
|
direction: current.direction === "asc" ? "desc" : "asc",
|
|
}))
|
|
}
|
|
>
|
|
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<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>
|
|
|
|
{tabs.map((tab) => {
|
|
const filteredJobs = visibleJobsForTab.get(tab.id) ?? [];
|
|
const trimmedQuery = searchQuery.trim();
|
|
|
|
return (
|
|
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
|
{filteredJobs.length === 0 ? (
|
|
<Card className="border-dashed bg-muted/20">
|
|
<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>
|
|
<p className="max-w-xl text-sm text-muted-foreground">
|
|
{trimmedQuery ? `No jobs match "${trimmedQuery}".` : emptyStateCopy[tab.id]}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
{viewMode === "table" ? (
|
|
<div className="space-y-2">
|
|
{tab.id === activeTab && selectedCount > 0 && (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
|
<div className="text-sm">
|
|
<span className="font-medium">{selectedCount}</span>{" "}
|
|
<span className="text-muted-foreground">selected</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => runBatch("process")}
|
|
disabled={batchAction !== null}
|
|
>
|
|
Generate Resumes
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => runBatch("reject")}
|
|
disabled={batchAction !== null}
|
|
>
|
|
Skip
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => runBatch("apply")}
|
|
disabled={batchAction !== null}
|
|
>
|
|
Mark Applied
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setSelectedJobIds(new Set())}
|
|
disabled={batchAction !== null}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<JobTable
|
|
jobs={filteredJobs}
|
|
sort={sort}
|
|
onSortChange={setSort}
|
|
selectedJobIds={selectedJobIds}
|
|
onSelectedJobIdsChange={setSelectedJobIds}
|
|
onApply={onApply}
|
|
onReject={onReject}
|
|
onProcess={onProcess}
|
|
processingJobId={processingJobId}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
);
|
|
};
|