| import React from "react"; |
| import { motion, AnimatePresence } from "framer-motion"; |
| import { |
| Briefcase, |
| Building2, |
| MapPin, |
| Clock, |
| ChevronDown, |
| Zap, |
| Cpu, |
| ExternalLink, |
| } from "lucide-react"; |
| import { Card, Button, Badge, Spinner } from "@/components/common"; |
| import { useJobStore } from "@/store/appStore"; |
| import { jobsAPI } from "@/utils/api"; |
| import { downloadFile } from "@/utils/helpers"; |
| import type { Job } from "@/types"; |
| import { JobFitAnalyzer } from "../sections/JobFitAnalyzer"; |
|
|
| interface JobListPageProps { |
| onBack?: () => void; |
| onHomeClick?: () => void; |
| onSettingsClick?: () => void; |
| } |
|
|
| const JobListPage: React.FC<JobListPageProps> = ({ |
| onBack, |
| onHomeClick, |
| onSettingsClick, |
| }) => { |
| const { |
| jobs, |
| setJobs, |
| selectedJobIds, |
| toggleJobSelection, |
| selectAllJobs, |
| deselectAllJobs, |
| isGenerating, |
| setIsGenerating, |
| batchProgress, |
| setBatchProgress, |
| addGeneratedCV, |
| setNotification, |
| tailoringMode, |
| llmProvider, |
| enableProfessionalSummary, |
| includeCoverLetters, |
| } = useJobStore(); |
|
|
| const [expandedJobId, setExpandedJobId] = React.useState<string | null>(null); |
| const [generatingJobId, setGeneratingJobId] = React.useState<string | null>( |
| null |
| ); |
|
|
| const selectedCount = selectedJobIds.size; |
| const allSelected = selectedCount === jobs.length && jobs.length > 0; |
|
|
| const getLLMProviderLabel = (provider: string) => { |
| const labels: Record<string, string> = { |
| ollama: "Ollama", |
| groq: "Groq", |
| grok: "Grok", |
| openai: "OpenAI", |
| }; |
| return labels[provider] || provider; |
| }; |
|
|
| const handleGenerateSingleCV = async (job: Job) => { |
| setGeneratingJobId(job.id); |
| try { |
| const response = await jobsAPI.generateCV(job.id); |
| if (response.success) { |
| |
| if (response.job) { |
| const updatedJobs = jobs.map((j) => |
| j.id === job.id ? { ...j, ...response.job } : j |
| ); |
| setJobs(updatedJobs as Job[]); |
| } |
|
|
| addGeneratedCV({ |
| jobId: job.id, |
| jobTitle: job.title, |
| company: job.company, |
| filename: response.filename, |
| url: response.url, |
| coverLetterFilename: response.cover_letter_filename, |
| coverLetterUrl: response.cover_letter_url, |
| generatedAt: new Date().toISOString(), |
| status: "success", |
| }); |
| const hasCoverLetter = !!response.cover_letter_filename; |
| setNotification({ |
| type: "success", |
| message: hasCoverLetter |
| ? `CV + Cover Letter generated for ${job.company} - ${job.title}` |
| : `CV generated for ${job.company} - ${job.title}`, |
| }); |
|
|
| |
| const blob = await jobsAPI.downloadFile(response.filename); |
| downloadFile(blob, response.filename); |
|
|
| if (response.cover_letter_filename) { |
| const coverLetterBlob = await jobsAPI.downloadFile( |
| response.cover_letter_filename |
| ); |
| downloadFile(coverLetterBlob, response.cover_letter_filename); |
| } |
| } |
| } catch (error) { |
| setNotification({ |
| type: "error", |
| message: "Failed to generate CV", |
| }); |
| } finally { |
| setGeneratingJobId(null); |
| } |
| }; |
|
|
| const handleGenerateAllCVs = async () => { |
| if (selectedCount === 0) { |
| setNotification({ |
| type: "error", |
| message: "Please select at least one job", |
| }); |
| return; |
| } |
|
|
| setIsGenerating(true); |
| setBatchProgress({ |
| total: selectedCount, |
| completed: 0, |
| failed: 0, |
| isProcessing: true, |
| }); |
|
|
| try { |
| const jobIds = Array.from(selectedJobIds); |
| const response = await jobsAPI.generateAllCVs(jobIds); |
|
|
| setBatchProgress({ |
| total: selectedCount, |
| completed: response.successful.length, |
| failed: response.failed.length, |
| isProcessing: false, |
| }); |
|
|
| response.successful.forEach((cv) => { |
| addGeneratedCV(cv); |
| }); |
|
|
| setNotification({ |
| type: "success", |
| message: `Generated ${response.successful.length} CVs successfully`, |
| }); |
|
|
| |
| const blob = await jobsAPI.downloadFile(response.zip_filename); |
| downloadFile(blob, response.zip_filename); |
| } catch (error) { |
| setNotification({ |
| type: "error", |
| message: "Batch generation failed", |
| }); |
| } finally { |
| setIsGenerating(false); |
| } |
| }; |
|
|
| if (jobs.length === 0) { |
| return ( |
| <div className="min-h-screen bg-gradient-saas flex items-center justify-center px-4"> |
| <Card className="text-center max-w-md border-emerald-500/20 shadow-[0_0_60px_rgba(34,197,94,0.08)]"> |
| <div className="w-16 h-16 bg-emerald-500/20 rounded-lg flex items-center justify-center mx-auto mb-4"> |
| <Briefcase className="w-8 h-8 text-emerald-400" /> |
| </div> |
| <h2 className="text-2xl font-bold text-white mb-2">No Jobs Found</h2> |
| <p className="text-slate-400 mb-6"> |
| Search for jobs to get started with CV generation |
| </p> |
| <div className="flex gap-3"> |
| <Button variant="secondary" onClick={onHomeClick} fullWidth> |
| Home |
| </Button> |
| <Button onClick={onBack} fullWidth> |
| Back to Search |
| </Button> |
| </div> |
| </Card> |
| </div> |
| ); |
| } |
|
|
| |
| |
|
|
| return ( |
| <div className="min-h-screen bg-gradient-saas px-4 sm:px-6 lg:px-8 py-8 max-w-7xl mx-auto"> |
| {/* Header */} |
| <motion.div |
| initial={{ opacity: 0, y: -20 }} |
| animate={{ opacity: 1, y: 0 }} |
| className="mb-8" |
| > |
| <div className="flex items-center justify-between mb-4"> |
| <Button variant="ghost" size="sm" onClick={onHomeClick}> |
| Home |
| </Button> |
| <div className="flex items-center gap-3"> |
| <div className="text-xs uppercase tracking-[0.3em] text-emerald-200/60"> |
| Tailored CVs |
| </div> |
| {tailoringMode === "api" && ( |
| <div className="flex items-center gap-1 px-2 py-1 bg-emerald-500/15 rounded-full border border-emerald-500/30"> |
| <Cpu className="w-3 h-3 text-emerald-400" /> |
| <span className="text-xs text-emerald-300 font-medium"> |
| {getLLMProviderLabel(llmProvider || "groq")} |
| </span> |
| </div> |
| )} |
| <div className="px-2 py-1 rounded-full border text-xs font-medium border-emerald-500/20 text-emerald-200/75"> |
| Summary: {enableProfessionalSummary ? "On" : "Off"} |
| </div> |
| <div className="px-2 py-1 rounded-full border text-xs font-medium border-emerald-500/20 text-emerald-200/75"> |
| Cover Letters: {includeCoverLetters ? "On" : "Off"} |
| </div> |
| {onSettingsClick && ( |
| <Button variant="ghost" size="sm" onClick={onSettingsClick}> |
| Settings |
| </Button> |
| )} |
| </div> |
| </div> |
| <div className="flex items-center justify-between mb-6"> |
| <div> |
| <h1 className="text-3xl font-bold text-white mb-2"> |
| Found {jobs.length} Jobs |
| </h1> |
| <p className="text-slate-400"> |
| Select jobs to generate tailored CVs |
| </p> |
| </div> |
| <Button variant="secondary" onClick={onBack}> |
| Back to Search |
| </Button> |
| </div> |
| |
| {/* Selection Controls */} |
| <Card className="flex items-center justify-between border-emerald-500/10 shadow-[0_0_40px_rgba(34,197,94,0.08)]"> |
| <div className="flex items-center gap-4"> |
| <input |
| type="checkbox" |
| checked={allSelected} |
| onChange={() => { |
| if (allSelected) { |
| deselectAllJobs(); |
| } else { |
| selectAllJobs(); |
| } |
| }} |
| className="w-5 h-5 rounded border-slate-600 cursor-pointer" |
| /> |
| <span className="text-slate-300"> |
| {selectedCount > 0 ? ( |
| <> |
| <strong>{selectedCount}</strong> job |
| {selectedCount !== 1 ? "s" : ""} selected |
| </> |
| ) : ( |
| "Select jobs to generate CVs" |
| )} |
| </span> |
| </div> |
| {selectedCount > 0 && ( |
| <Button |
| size="sm" |
| loading={isGenerating} |
| onClick={handleGenerateAllCVs} |
| icon={<Zap className="w-4 h-4" />} |
| > |
| Generate All ({selectedCount}) |
| </Button> |
| )} |
| </Card> |
| </motion.div> |
|
|
| {} |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ staggerChildren: 0.1 }} |
| className="space-y-4" |
| > |
| {jobs.map((job, index) => { |
| |
| const matchedSkills = job.matched_skills || []; |
| const matchedCategories = job.matched_categories || {}; |
| const isRawData = matchedSkills.length === 0; |
|
|
| console.log(job); |
|
|
| return ( |
| <motion.div |
| key={job.id || index} // Fallback to index if id is missing in raw data |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ delay: index * 0.05 }} |
| > |
| <Card hover noPadding className="overflow-hidden"> |
| <div className="p-6"> |
| {/* ========================================== */} |
| {/* TOP SECTION: Header, Meta, and Action Footer */} |
| {/* ========================================== */} |
| <div className="flex items-start gap-4"> |
| <input |
| type="checkbox" |
| checked={selectedJobIds.has(job.id)} |
| onChange={() => toggleJobSelection(job.id)} |
| className="w-5 h-5 rounded border-slate-600 cursor-pointer mt-1 shrink-0" |
| /> |
| |
| <div className="flex-1 min-w-0 w-full"> |
| {/* Top Row: Title, Company & Expand Chevron */} |
| <div className="flex items-start justify-between mb-2"> |
| <div className="pr-4"> |
| <h3 className="text-lg font-semibold text-white truncate"> |
| {job.title} |
| </h3> |
| <p className="text-emerald-400 font-medium"> |
| {job.company} |
| </p> |
| </div> |
| <motion.button |
| onClick={() => |
| setExpandedJobId( |
| expandedJobId === job.id ? null : job.id |
| ) |
| } |
| animate={{ |
| rotate: expandedJobId === job.id ? 180 : 0, |
| }} |
| className="text-slate-400 hover:text-white transition-colors shrink-0 p-1" |
| > |
| <ChevronDown className="w-5 h-5" /> |
| </motion.button> |
| </div> |
| |
| {/* Job Meta with Safeties for missing data */} |
| <div className="flex flex-wrap gap-4 text-sm text-slate-400 mb-3"> |
| {job.location && ( |
| <div className="flex items-center gap-1"> |
| <MapPin className="w-4 h-4" /> |
| {job.location} |
| </div> |
| )} |
| <div className="flex items-center gap-1"> |
| <Clock className="w-4 h-4" /> |
| {job.posted_days_ago} days ago |
| </div> |
| <div className="flex items-center gap-1"> |
| <Building2 className="w-4 h-4" /> |
| {job.source} |
| </div> |
| {job.link && ( |
| <motion.a |
| href={job.link} |
| target="_blank" |
| rel="noopener noreferrer" |
| whileHover={{ x: 2 }} |
| className="flex items-center gap-1 text-emerald-400 hover:text-emerald-300 transition-colors font-medium" |
| > |
| View on LinkedIn |
| <ExternalLink className="w-4 h-4" /> |
| </motion.a> |
| )} |
| </div> |
| |
| {/* Skills Preview with Safeties */} |
| <div className="flex flex-wrap gap-2 mb-4"> |
| {matchedSkills.slice(0, 3).map((skill) => ( |
| <Badge key={skill} variant="primary" size="sm"> |
| {skill} |
| </Badge> |
| ))} |
| {matchedSkills.length > 3 && ( |
| <Badge variant="secondary" size="sm"> |
| +{matchedSkills.length - 3} more |
| </Badge> |
| )} |
| {/* Show a badge indicating this is fast/raw data */} |
| {isRawData && ( |
| <Badge |
| variant="secondary" |
| size="sm" |
| className="opacity-70 border-slate-600" |
| > |
| Ready for AI CV Tailoring |
| </Badge> |
| )} |
| </div> |
| |
| {/* --- FULL-WIDTH ACTION FOOTER --- */} |
| <div className="w-full pt-4 mt-2 border-t border-slate-700/50"> |
| <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 w-full"> |
| {/* Left Side: Analyzer Pill & Skills Drawer */} |
| <div className="flex-1 w-full min-w-0"> |
| <JobFitAnalyzer jobId={job.id} /> |
| </div> |
| |
| {/* Right Side: Generate CV Button */} |
| <div className="shrink-0 mt-1"> |
| <Button |
| size="sm" |
| loading={generatingJobId === job.id} |
| onClick={() => handleGenerateSingleCV(job)} |
| disabled={generatingJobId !== null} |
| className="bg-emerald-500 hover:bg-emerald-600 text-white shadow-lg shadow-emerald-500/20" |
| > |
| Generate CV |
| </Button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* ========================================== */} |
| {/* BOTTOM SECTION: Collapsible Job Description */} |
| {/* ========================================== */} |
| <AnimatePresence> |
| {expandedJobId === job.id && ( |
| <motion.div |
| initial={{ opacity: 0, height: 0 }} |
| animate={{ opacity: 1, height: "auto" }} |
| exit={{ opacity: 0, height: 0 }} |
| className="border-t border-slate-700 pt-4 mt-6" |
| > |
| {/* LinkedIn Link Button */} |
| {(job.apply_url || job.link) && ( |
| <motion.a |
| href={job.apply_url} |
| target="_blank" |
| rel="noopener noreferrer" |
| whileHover={{ scale: 1.02 }} |
| className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 hover:border-emerald-500/50 text-emerald-300 hover:text-emerald-200 rounded-lg transition-all duration-200 font-medium text-sm mb-4" |
| > |
| <ExternalLink className="w-4 h-4" /> |
| Visit Job Post Page |
| </motion.a> |
| )} |
| |
| {/* Skill Categories with Safeties */} |
| {Object.keys(matchedCategories).length > 0 && ( |
| <div className="mb-4"> |
| <h4 className="font-semibold text-white mb-3"> |
| Matched Skills |
| </h4> |
| <div className="space-y-3"> |
| {Object.entries(matchedCategories).map( |
| ([category, skills]) => ( |
| <div key={category}> |
| <p className="text-sm font-medium text-slate-300 mb-2"> |
| {category} |
| </p> |
| <div className="flex flex-wrap gap-2"> |
| {(skills as string[]).map((skill) => ( |
| <Badge |
| key={skill} |
| variant="success" |
| size="sm" |
| > |
| {skill} |
| </Badge> |
| ))} |
| </div> |
| </div> |
| ) |
| )} |
| </div> |
| </div> |
| )} |
| |
| {/* Job Description Preview */} |
| <div> |
| <h4 className="font-semibold text-white mb-2"> |
| Job Description |
| </h4> |
| <p className="text-sm text-slate-300 whitespace-pre-wrap"> |
| {job.description || "No description provided."} |
| </p> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| </Card> |
| </motion.div> |
| ); |
| })} |
| </motion.div> |
|
|
| {} |
| {batchProgress.isProcessing && ( |
| <div className="fixed bottom-4 right-4"> |
| <Card glow> |
| <div className="flex items-center gap-3"> |
| <Spinner size="sm" /> |
| <div> |
| <p className="text-sm font-medium text-white"> |
| {batchProgress.completed} / {batchProgress.total} CVs |
| Generated |
| </p> |
| {batchProgress.current && ( |
| <p className="text-xs text-slate-400"> |
| {batchProgress.current} |
| </p> |
| )} |
| </div> |
| </div> |
| </Card> |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| export default JobListPage; |
|
|