370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
/**
|
|
* Table-based job list view.
|
|
*/
|
|
|
|
import React from "react";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
ArrowUpDown,
|
|
CheckCircle2,
|
|
Copy,
|
|
Download,
|
|
ExternalLink,
|
|
MoreHorizontal,
|
|
RefreshCcw,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
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 { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
|
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;
|
|
selectedJobIds: Set<string>;
|
|
onSelectedJobIdsChange: (ids: Set<string>) => void;
|
|
onApply: (id: string) => void | Promise<void>;
|
|
onReject: (id: string) => void | Promise<void>;
|
|
onProcess: (id: string) => void | Promise<void>;
|
|
processingJobId: string | null;
|
|
highlightedJobId?: string | null;
|
|
onHighlightChange?: (jobId: string | null) => void;
|
|
}
|
|
|
|
const sourceLabel: Record<Job["source"], string> = {
|
|
gradcracker: "Gradcracker",
|
|
indeed: "Indeed",
|
|
linkedin: "LinkedIn",
|
|
ukvisajobs: "UK Visa Jobs",
|
|
};
|
|
|
|
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,
|
|
selectedJobIds,
|
|
onSelectedJobIdsChange,
|
|
onApply,
|
|
onReject,
|
|
onProcess,
|
|
processingJobId,
|
|
highlightedJobId,
|
|
onHighlightChange,
|
|
}) => {
|
|
const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0);
|
|
const allSelected = jobs.length > 0 && selectedCount === jobs.length;
|
|
const someSelected = selectedCount > 0 && selectedCount < jobs.length;
|
|
|
|
const handleCopyInfo = async (job: Job) => {
|
|
try {
|
|
await copyTextToClipboard(formatJobForWebhook(job));
|
|
toast.success("Copied job info", { description: "Webhook payload copied to clipboard." });
|
|
} catch {
|
|
toast.error("Could not copy job info");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Table className="table-fixed">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
aria-label="Select all rows"
|
|
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
onCheckedChange={(checked) => {
|
|
const next = new Set(selectedJobIds);
|
|
if (checked) {
|
|
for (const job of jobs) next.add(job.id);
|
|
} else {
|
|
for (const job of jobs) next.delete(job.id);
|
|
}
|
|
onSelectedJobIdsChange(next);
|
|
}}
|
|
/>
|
|
</TableHead>
|
|
<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 = ["discovered", "ready"].includes(job.status);
|
|
const canReject = ["discovered", "ready"].includes(job.status);
|
|
const isProcessing = processingJobId === job.id;
|
|
const isSelected = selectedJobIds.has(job.id);
|
|
const isHighlighted = highlightedJobId === job.id;
|
|
|
|
return (
|
|
<TableRow key={job.id} data-state={isSelected ? "selected" : undefined}>
|
|
<TableCell className="align-middle">
|
|
<Checkbox
|
|
aria-label={`Select ${job.title}`}
|
|
checked={isSelected}
|
|
onCheckedChange={(checked) => {
|
|
const next = new Set(selectedJobIds);
|
|
if (checked) next.add(job.id);
|
|
else next.delete(job.id);
|
|
onSelectedJobIdsChange(next);
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="align-middle">
|
|
<Button
|
|
asChild
|
|
variant="link"
|
|
size="sm"
|
|
className="h-auto justify-start p-0 text-left leading-snug whitespace-normal wrap-break-word"
|
|
>
|
|
<a href={jobLink} target="_blank" rel="noopener noreferrer">
|
|
{job.title}
|
|
</a>
|
|
</Button>
|
|
</TableCell>
|
|
|
|
<TableCell className="align-middle whitespace-normal wrap-break-word">
|
|
{job.employer}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Badge variant="outline" className="uppercase tracking-wide">
|
|
{sourceLabel[job.source]}
|
|
</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell className="align-middle whitespace-normal wrap-break-word 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>
|
|
|
|
<DropdownMenuItem onSelect={() => void handleCopyInfo(job)}>
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
Copy info
|
|
</DropdownMenuItem>
|
|
|
|
{onHighlightChange && (
|
|
<DropdownMenuItem
|
|
onSelect={() => onHighlightChange(isHighlighted ? null : job.id)}
|
|
>
|
|
{isHighlighted ? "Unhighlight" : "Highlight"}
|
|
</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..."
|
|
: job.status === "ready"
|
|
? "Regenerate PDF"
|
|
: "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>
|
|
);
|
|
};
|