2025-12-15 18:56:12 +00:00

226 lines
6.5 KiB
TypeScript

/**
* Individual job card component.
*/
import React from "react";
import {
Calendar,
CheckCircle2,
Copy,
DollarSign,
Download,
ExternalLink,
GraduationCap,
Loader2,
MapPin,
RefreshCcw,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { copyTextToClipboard, formatJobForLlmContext } from "@client/lib/jobCopy";
import type { Job } from "../../shared/types";
import { ScoreIndicator } from "./ScoreIndicator";
import { StatusBadge } from "./StatusBadge";
interface JobCardProps {
job: Job;
onApply: (id: string) => void | Promise<void>;
onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>;
isProcessing: boolean;
highlightedJobId?: string | null;
onHighlightChange?: (jobId: string | null) => void;
}
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, "_");
export const JobCard: React.FC<JobCardProps> = ({
job,
onApply,
onReject,
onProcess,
isProcessing,
highlightedJobId,
onHighlightChange,
}) => {
const sourceLabel: Record<Job["source"], string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
linkedin: "LinkedIn",
};
const hasPdf = !!job.pdfPath;
const canApply = job.status === "ready";
const canProcess = job.status === "discovered";
const canReject = ["discovered", "ready"].includes(job.status);
const jobLink = job.applicationLink || job.jobUrl;
const pdfHref = `/pdfs/resume_${job.id}.pdf`;
const deadline = formatDate(job.deadline);
const isHighlighted = highlightedJobId === job.id;
const handleCopyInfo = async () => {
try {
await copyTextToClipboard(formatJobForLlmContext(job));
toast.success("Copied job info", { description: "LLM-ready context copied to clipboard." });
} catch {
toast.error("Could not copy job info");
}
};
return (
<Card>
<CardHeader className="space-y-3">
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 space-y-1">
<CardTitle className="text-base leading-tight">{job.title}</CardTitle>
<div className="text-sm text-muted-foreground">{job.employer}</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<ScoreIndicator score={job.suitabilityScore} />
<Badge variant="outline" className="uppercase tracking-wide">
{sourceLabel[job.source]}
</Badge>
<StatusBadge status={job.status} />
</div>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{job.location}
</span>
)}
{deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{deadline}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-4 w-4" />
{job.salary}
</span>
)}
{job.degreeRequired && (
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4" />
{job.degreeRequired}
</span>
)}
</div>
</CardHeader>
{(job.suitabilityReason || canApply || canReject || canProcess || hasPdf) && (
<CardContent className="space-y-3">
{job.suitabilityReason && (
<p className="text-sm italic text-muted-foreground">
&quot;{job.suitabilityReason}&quot;
</p>
)}
</CardContent>
)}
<CardFooter className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<a href={jobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View Job
</a>
</Button>
<Button variant="outline" size="sm" onClick={handleCopyInfo}>
<Copy className="mr-2 h-4 w-4" />
Copy info
</Button>
{onHighlightChange && (
<Button
variant="outline"
size="sm"
onClick={() => onHighlightChange(isHighlighted ? null : job.id)}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</Button>
)}
{hasPdf && (
<Button asChild variant="outline" size="sm">
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View PDF
</a>
</Button>
)}
{hasPdf && (
<Button asChild variant="outline" size="sm">
<a
href={pdfHref}
download={`resume_${safeFilenamePart(job.employer)}_${safeFilenamePart(job.title)}.pdf`}
>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
)}
{canProcess && (
<Button
variant="secondary"
size="sm"
onClick={() => onProcess(job.id)}
disabled={isProcessing}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<RefreshCcw className="mr-2 h-4 w-4" />
Generate Resume
</>
)}
</Button>
)}
{canReject && (
<Button variant="destructive" size="sm" onClick={() => onReject(job.id)}>
<XCircle className="mr-2 h-4 w-4" />
Skip
</Button>
)}
{canApply && (
<Button size="sm" onClick={() => onApply(job.id)}>
<CheckCircle2 className="mr-2 h-4 w-4" />
Mark Applied
</Button>
)}
</CardFooter>
</Card>
);
};