/** * Orchestrator layout with a split list/detail experience. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowUpDown, Calendar, CheckCircle2, ChevronDown, Clock, Copy, DollarSign, ExternalLink, FileText, Filter, GraduationCap, Loader2, MapPin, MoreHorizontal, Play, RefreshCcw, Search, Settings, Sparkles, XCircle, } from "lucide-react"; import { Link } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; import { PipelineProgress } from "../components"; import * as api from "../api"; import { TailoringEditor } from "../components/TailoringEditor"; import type { Job, JobSource, JobStatus } from "../../shared/types"; const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources"; const sourceLabel: Record = { gradcracker: "Gradcracker", indeed: "Indeed", linkedin: "LinkedIn", ukvisajobs: "UK Visa Jobs", }; const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; const statusTokens: Record< JobStatus, { label: string; badge: string; dot: string } > = { discovered: { label: "Discovered", badge: "border-sky-500/30 bg-sky-500/10 text-sky-200", dot: "bg-sky-400", }, processing: { label: "Processing", badge: "border-amber-500/30 bg-amber-500/10 text-amber-200", dot: "bg-amber-400", }, ready: { label: "Ready", badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", dot: "bg-emerald-400", }, applied: { label: "Applied", badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", dot: "bg-emerald-400", }, rejected: { label: "Rejected", badge: "border-rose-500/30 bg-rose-500/10 text-rose-200", dot: "bg-rose-400", }, expired: { label: "Expired", badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground", dot: "bg-muted-foreground", }, }; type FilterTab = "ready" | "discovered" | "applied" | "all"; type SortKey = "discoveredAt" | "score" | "title" | "employer"; type SortDirection = "asc" | "desc"; interface JobSort { key: SortKey; direction: SortDirection; } const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" }; const sortLabels: Record = { discoveredAt: "Discovered", score: "Score", title: "Title", employer: "Company", }; const defaultSortDirection: Record = { discoveredAt: "desc", score: "desc", title: "asc", employer: "asc", }; 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 = { ready: "Run the pipeline to discover and process new jobs.", discovered: "All discovered jobs have been processed.", applied: "You have not applied to any jobs yet.", all: "No jobs in the system yet. Run the pipeline to get started.", }; 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 formatDateTime = (dateStr: string | null) => { if (!dateStr) return null; try { const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); const parsed = new Date(normalized); if (Number.isNaN(parsed.getTime())) return dateStr; const date = parsed.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", }); const time = parsed.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", }); return `${date} ${time}`; } catch { return dateStr; } }; const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_"); const dateValue = (value: string | null) => { if (!value) return null; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; }; 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 "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": const aDate = dateValue(a.discoveredAt); const bDate = dateValue(b.discoveredAt); if (aDate == null && bDate == null) { value = 0; break; } if (aDate == null) return 1; if (bDate == null) return -1; value = compareNumber(aDate, bDate); break; default: value = 0; } if (value !== 0) return sort.direction === "asc" ? value : -value; 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.source, job.status, job.jobType, job.jobFunction, ] .filter(Boolean) .join(" ") .toLowerCase(); return haystack.includes(normalized); }; const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { const tokens = statusTokens[status]; return ( {tokens.label} ); }; const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { if (score == null) { return Not scored; } return (
{score}
); }; export const OrchestratorPage: React.FC = () => { const [jobs, setJobs] = useState([]); const [stats, setStats] = useState>({ discovered: 0, processing: 0, ready: 0, applied: 0, rejected: 0, expired: 0, }); const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); const [processingJobId, setProcessingJobId] = useState(null); const [activeTab, setActiveTab] = useState("ready"); const [searchQuery, setSearchQuery] = useState(""); const [sourceFilter, setSourceFilter] = useState("all"); const [sort, setSort] = useState(DEFAULT_SORT); const [selectedJobId, setSelectedJobId] = useState(null); const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); const [isEditingDescription, setIsEditingDescription] = useState(false); const [editedDescription, setEditedDescription] = useState(""); const [isSavingDescription, setIsSavingDescription] = useState(false); const [pipelineSources, setPipelineSources] = useState(() => { try { const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); if (!raw) return DEFAULT_PIPELINE_SOURCES; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES; const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource)); return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES; } catch { return DEFAULT_PIPELINE_SOURCES; } }); useEffect(() => { try { localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources)); } catch { // Ignore localStorage errors } }, [pipelineSources]); const loadJobs = useCallback(async () => { try { setIsLoading(true); const data = await api.getJobs(); setJobs(data.jobs); setStats(data.byStatus); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); } finally { setIsLoading(false); } }, []); const checkPipelineStatus = useCallback(async () => { try { const status = await api.getPipelineStatus(); setIsPipelineRunning(status.isRunning); } catch { // Ignore errors } }, []); useEffect(() => { loadJobs(); checkPipelineStatus(); const interval = setInterval(() => { loadJobs(); checkPipelineStatus(); }, 10000); return () => clearInterval(interval); }, [loadJobs, checkPipelineStatus]); const handleRunPipeline = async () => { try { setIsPipelineRunning(true); await api.runPipeline({ sources: pipelineSources }); toast.message("Pipeline started", { description: `Sources: ${pipelineSources.join(", ")}. This may take a few minutes.`, }); const pollInterval = setInterval(async () => { try { const status = await api.getPipelineStatus(); if (!status.isRunning) { clearInterval(pollInterval); setIsPipelineRunning(false); await loadJobs(); toast.success("Pipeline completed"); } } catch { // Ignore errors } }, 5000); } catch (error) { setIsPipelineRunning(false); const message = error instanceof Error ? error.message : "Failed to start pipeline"; toast.error(message); } }; const handleProcess = async (jobId: string) => { try { setProcessingJobId(jobId); const job = jobs.find((item) => item.id === jobId); const force = job?.status === "ready"; await api.processJob(jobId, { force }); toast.success(force ? "Resume regenerated successfully" : "Resume generated successfully"); await loadJobs(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to process job"; toast.error(message); } finally { setProcessingJobId(null); } }; const handleApply = async (jobId: string) => { try { await api.markAsApplied(jobId); toast.success("Marked as applied"); await loadJobs(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to mark as applied"; toast.error(message); } }; const handleReject = async (jobId: string) => { try { await api.rejectJob(jobId); toast.message("Job skipped"); await loadJobs(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to reject job"; toast.error(message); } }; 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"); } }; const activeJobs = useMemo(() => { let filtered = jobs; if (activeTab === "ready") { filtered = filtered.filter((job) => job.status === "ready"); } else if (activeTab === "discovered") { filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing"); } else if (activeTab === "applied") { filtered = filtered.filter((job) => job.status === "applied"); } if (sourceFilter !== "all") { filtered = filtered.filter((job) => job.source === sourceFilter); } if (searchQuery.trim()) { filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery)); } return [...filtered].sort((a, b) => compareJobs(a, b, sort)); }, [jobs, activeTab, sourceFilter, searchQuery, sort]); useEffect(() => { if (activeJobs.length === 0) { setSelectedJobId(null); return; } if (!selectedJobId || !activeJobs.some((job) => job.id === selectedJobId)) { setSelectedJobId(activeJobs[0].id); } }, [activeJobs, selectedJobId]); const selectedJob = useMemo( () => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null), [jobs, selectedJobId], ); const description = useMemo(() => { if (!selectedJob?.jobDescription) return "No description available."; const jd = selectedJob.jobDescription; if (jd.includes("<") && jd.includes(">")) return stripHtml(jd); return jd; }, [selectedJob]); useEffect(() => { if (!selectedJob) { setIsEditingDescription(false); setEditedDescription(""); return; } setIsEditingDescription(false); setEditedDescription(selectedJob.jobDescription || ""); }, [selectedJob?.id]); useEffect(() => { if (!selectedJob) return; if (!isEditingDescription) { setEditedDescription(selectedJob.jobDescription || ""); } }, [selectedJob?.jobDescription, isEditingDescription]); const handleSaveDescription = async () => { if (!selectedJob) return; try { setIsSavingDescription(true); await api.updateJob(selectedJob.id, { jobDescription: editedDescription }); toast.success("Job description updated"); setIsEditingDescription(false); await loadJobs(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update description"; toast.error(message); } finally { setIsSavingDescription(false); } }; const totalJobs = Object.values(stats).reduce((a, b) => a + b, 0); const activeResultsCount = activeJobs.length; const selectedHasPdf = !!selectedJob?.pdfPath; const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#"; const selectedPdfHref = selectedJob ? `/pdfs/resume_${selectedJob.id}.pdf` : "#"; const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null; const selectedDiscoveredAt = selectedJob ? formatDateTime(selectedJob.discoveredAt) : null; const canApply = selectedJob?.status === "ready"; const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; const canReject = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false; const showReadyPdf = activeTab === "ready"; const showGeneratePdf = activeTab === "discovered"; const isProcessingSelected = selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false; const toggleSource = (source: JobSource, checked: boolean) => { const next = checked ? Array.from(new Set([...pipelineSources, source])) : pipelineSources.filter((s) => s !== source); if (next.length === 0) return; setPipelineSources(next); }; const counts = useMemo(() => { const byTab: Record = { 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]); return ( <>
Job Ops
Orchestrator
{isPipelineRunning && ( Pipeline running )}
Sources {orderedSources.map((source) => ( toggleSource(source, Boolean(checked))} > {sourceLabel[source]} ))} setPipelineSources(orderedSources)}>All sources setPipelineSources(["gradcracker"])}>Gradcracker only setPipelineSources(["indeed", "linkedin"])}> Indeed + LinkedIn only

Pipeline console

Focused workspace with a split list/detail layout and icon-led actions.

{totalJobs} total jobs
{isPipelineRunning && (
)}
{[ { label: "Discovered", value: stats.discovered }, { label: "Processing", value: stats.processing }, { label: "Ready", value: stats.ready }, { label: "Applied", value: stats.applied }, { label: "Rejected", value: stats.rejected }, { label: "Expired", value: stats.expired }, ].map((item, index) => (
0 && "border-t border-border/60 sm:border-t-0 sm:border-l", index > 0 && index % 3 === 0 && "sm:border-l-0 sm:border-t", index > 2 && "lg:border-t-0 lg:border-l", )} > {item.label} {item.value}
))}
setActiveTab(value as FilterTab)}>
{tabs.map((tab) => ( {tab.label} ({counts[tab.id]}) ))}
setSearchQuery(event.target.value)} placeholder="Filter jobs..." className="pl-9" />
Filter by source setSourceFilter(value as JobSource | "all")} > All Sources {(Object.keys(sourceLabel) as JobSource[]).map((key) => ( {sourceLabel[key]} ))} Sort by setSort({ key: value as JobSort["key"], direction: defaultSortDirection[value as JobSort["key"]], }) } > {(Object.keys(sortLabels) as Array).map((key) => ( {sortLabels[key]} ))} setSort((current) => ({ ...current, direction: current.direction === "asc" ? "desc" : "asc", })) } > Direction: {sort.direction === "asc" ? "Ascending" : "Descending"} {activeResultsCount} jobs
{activeJobs.length === 0 ? (
No jobs found

{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}

) : (
{activeJobs.map((job) => { const isSelected = job.id === selectedJobId; return ( ); })}
)}
{!selectedJob ? (
Select a job

Pick a job from the list to see details here.

) : (
{selectedJob.title}
{selectedJob.employer}
{sourceLabel[selectedJob.source]}
{selectedJob.location && ( {selectedJob.location} )} {selectedDeadline && ( {selectedDeadline} )} {selectedDiscoveredAt && ( Discovered {selectedDiscoveredAt} )} {selectedJob.salary && ( {selectedJob.salary} )} {selectedJob.degreeRequired && ( {selectedJob.degreeRequired} )}
Suitability
{showReadyPdf && (selectedHasPdf ? ( ) : ( ))} {showGeneratePdf && ( )} {canApply && ( )} {canProcess && !showGeneratePdf && ( handleProcess(selectedJob.id)} disabled={isProcessingSelected} > {isProcessingSelected ? "Processing..." : selectedJob.status === "ready" ? "Regenerate PDF" : "Generate PDF"} )} void handleCopyInfo(selectedJob)}> Copy info {selectedHasPdf && ( <> {!showReadyPdf && ( View PDF )} Download PDF )} {canReject && ( <> handleReject(selectedJob.id)} className="text-destructive focus:text-destructive" > Skip job )}
setDetailTab(value as typeof detailTab)}> Overview Tailoring Description
{selectedJob.suitabilityReason ? `"${selectedJob.suitabilityReason}"` : "No suitability summary yet."}
Discipline
{selectedJob.disciplines || "Not set"}
Job function
{selectedJob.jobFunction || "Not set"}
Job level
{selectedJob.jobLevel || "Not set"}
Job type
{selectedJob.jobType || "Not set"}
Job description
{!isEditingDescription ? ( setIsEditingDescription(true)}> Edit description ) : ( <> {isSavingDescription ? "Saving..." : "Save changes"} { setIsEditingDescription(false); setEditedDescription(selectedJob.jobDescription || ""); }} disabled={isSavingDescription} > Cancel edit )}
{isEditingDescription ? (