initial table implementation
This commit is contained in:
parent
68550952d4
commit
3056369a75
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
297
orchestrator/src/client/components/JobTable.tsx
Normal file
297
orchestrator/src/client/components/JobTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|||||||
23
orchestrator/src/components/ui/input.tsx
Normal file
23
orchestrator/src/components/ui/input.tsx
Normal 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 }
|
||||||
|
|
||||||
113
orchestrator/src/components/ui/table.tsx
Normal file
113
orchestrator/src/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user