diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 317feb3..94d528a 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -1,357 +1,50 @@ -/** +/** * Orchestrator layout with a split list/detail experience. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ArrowUpDown, - Briefcase, - Calendar, - CheckCircle2, - ChevronDown, - Copy, - DollarSign, - Edit2, - ExternalLink, - FileText, - Filter, - Home, - Loader2, - MapPin, - Menu, - MoreHorizontal, - Play, - RefreshCcw, - Save, - Search, - Settings, - Shield, - Sparkles, - XCircle, -} from "lucide-react"; -import { Link, useLocation, useNavigate } from "react-router-dom"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; -import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; -import { formatDate, formatDateTime } from "../lib/dateUtils"; -import { PipelineProgress, DiscoveredPanel, ManualImportSheet } from "../components"; -import { ReadyPanel } from "../components/ReadyPanel"; + +import { ManualImportSheet } 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", - manual: "Manual", -}; - -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", - }, - skipped: { - label: "Skipped", - 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 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(); - -// Default fallback for unknown statuses -const defaultStatusToken = { - label: "Unknown", - badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground", - dot: "bg-muted-foreground", -}; - -// Subdued status pill for inspector panel - not competing with list -const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { - const tokens = statusTokens[status] ?? defaultStatusToken; - return ( - - - {tokens.label} - - ); -}; - -// Compact score meter for inspector panel -const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { - if (score == null) { - return ; - } - - return ( -
-
-
-
- {score} -
- ); -}; +import type { JobSource } from "../../shared/types"; +import { DEFAULT_SORT } from "./orchestrator/constants"; +import type { FilterTab, JobSort } from "./orchestrator/constants"; +import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; +import { JobListPanel } from "./orchestrator/JobListPanel"; +import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters"; +import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader"; +import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary"; +import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; +import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; +import { usePipelineSources } from "./orchestrator/usePipelineSources"; +import { getJobCounts } from "./orchestrator/utils"; export const OrchestratorPage: React.FC = () => { - const location = useLocation(); - const navigate = useNavigate(); const [navOpen, setNavOpen] = useState(false); - const [jobs, setJobs] = useState([]); - const [stats, setStats] = useState>({ - discovered: 0, - processing: 0, - ready: 0, - applied: 0, - skipped: 0, - expired: 0, - }); - - const navLinks = [ - { to: "/", label: "Dashboard", icon: Home }, - { to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield }, - { to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase }, - { to: "/settings", label: "Settings", icon: Settings }, - ]; - const [isLoading, setIsLoading] = useState(true); - const [isPipelineRunning, setIsPipelineRunning] = useState(false); const [isManualImportOpen, setIsManualImportOpen] = 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 [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDesktop, setIsDesktop] = useState( () => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false), ); - const saveTailoringRef = useRef Promise)>(null); - 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 { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(); + const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); - 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 - } - }, []); + const activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort); + const counts = useMemo(() => getJobCounts(jobs), [jobs]); + const selectedJob = useMemo( + () => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null), + [jobs, selectedJobId], + ); const handleManualImported = useCallback( async (jobId: string) => { @@ -363,18 +56,6 @@ export const OrchestratorPage: React.FC = () => { [loadJobs], ); - useEffect(() => { - loadJobs(); - checkPipelineStatus(); - - const interval = setInterval(() => { - loadJobs(); - checkPipelineStatus(); - }, 10000); - - return () => clearInterval(interval); - }, [loadJobs, checkPipelineStatus]); - const handleRunPipeline = async () => { try { setIsPipelineRunning(true); @@ -403,85 +84,13 @@ export const OrchestratorPage: React.FC = () => { } }; - const handleProcess = async (jobId: string) => { - try { - const job = jobs.find((item) => item.id === jobId); - if (!job) throw new Error("Job not found"); - - const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true }); - if (!shouldProceed) return; - - setProcessingJobId(jobId); - - if (job.status === "ready") { - await api.generateJobPdf(jobId); - toast.success("Resume regenerated successfully"); - } else { - await api.processJob(jobId); - toast.success("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 handleSelectJob = (jobId: string) => { + setSelectedJobId(jobId); + if (!isDesktop) { + setIsDetailDrawerOpen(true); } }; - 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 handleSkip = async (jobId: string) => { - try { - await api.skipJob(jobId); - toast.message("Job skipped"); - await loadJobs(); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to skip 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); @@ -517,882 +126,63 @@ export const OrchestratorPage: React.FC = () => { } }, [isDesktop, isDetailDrawerOpen]); - const selectedJob = useMemo( - () => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null), - [jobs, selectedJobId], - ); - - useEffect(() => { - setHasUnsavedTailoring(false); - saveTailoringRef.current = null; - }, [selectedJob?.id]); - - 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 hasUnsavedDescription = - !!selectedJob && - isEditingDescription && - editedDescription !== (selectedJob.jobDescription || ""); - - const confirmAndSaveEdits = useCallback( - async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => { - const pendingDescription = hasUnsavedDescription; - const pendingTailoring = includeTailoring && hasUnsavedTailoring; - - if (!pendingDescription && !pendingTailoring) return true; - - const parts = []; - if (pendingDescription) parts.push("job description"); - if (pendingTailoring) parts.push("tailoring changes"); - - const message = `You have unsaved ${parts.join(" and ")}. Save before generating the PDF?`; - if (!window.confirm(message)) return false; - - try { - if (pendingDescription && selectedJob) { - await api.updateJob(selectedJob.id, { jobDescription: editedDescription }); - } - - if (pendingTailoring) { - const saveTailoring = saveTailoringRef.current; - if (!saveTailoring) { - toast.error("Could not save tailoring changes"); - return false; - } - await saveTailoring(); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to save changes"; - toast.error(errorMessage); - return false; - } - - return true; - }, - [editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob], - ); - - 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?v=${encodeURIComponent(selectedJob.updatedAt)}` - : "#"; - 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 canSkip = 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 handleSelectJob = (jobId: string) => { - setSelectedJobId(jobId); - if (!isDesktop) { - setIsDetailDrawerOpen(true); - } - }; - - const detailPanelContent = - activeTab === "discovered" ? ( - { - // Select next job in list after current one is moved - const currentIndex = activeJobs.findIndex((j) => j.id === jobId); - const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1]; - setSelectedJobId(nextJob?.id ?? null); - }} - /> - ) : activeTab === "ready" ? ( - /* ReadyPanel for Ready tab - shipping lane workflow: verify + download + apply + mark applied */ - { - // Select next job in list after current one is moved - const currentIndex = activeJobs.findIndex((j) => j.id === jobId); - const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1]; - setSelectedJobId(nextJob?.id ?? null); - }} - onEditTailoring={() => { - setActiveTab("discovered"); - // Brief delay to let tab switch, then we're showing generic panel with tailoring - setTimeout(() => setDetailTab("tailoring"), 50); - }} - onEditDescription={() => { - setActiveTab("discovered"); - setTimeout(() => { - setDetailTab("description"); - setIsEditingDescription(true); - }, 50); - }} - /> - ) : !selectedJob ? ( -
-
No job selected
-

Select a job to view details

-
- ) : ( -
- {/* Detail header: lighter weight than list items */} -
-
-
{selectedJob.title}
-
{selectedJob.employer}
-
- - {sourceLabel[selectedJob.source]} - -
- - {/* Tertiary metadata - subdued */} -
- {selectedJob.location && ( - - - {selectedJob.location} - - )} - {selectedDeadline && ( - - - {selectedDeadline} - - )} - {selectedJob.salary && ( - - - {selectedJob.salary} - - )} -
- - {/* Status and score: single line, subdued */} -
- - -
- -
- - - {showReadyPdf && - (selectedHasPdf ? ( - - ) : ( - - ))} - - {showGeneratePdf && ( - - )} - - {canApply && ( - - )} - - - - - - - {canProcess && !showGeneratePdf && ( - handleProcess(selectedJob.id)} - disabled={isProcessingSelected} - > - - {isProcessingSelected - ? "Processing..." - : selectedJob.status === "ready" - ? "Regenerate PDF" - : "Generate PDF"} - - )} - { - setDetailTab("description"); - setIsEditingDescription(true); - }} - > - - Edit description - - void handleCopyInfo(selectedJob)}> - - Copy info - - {selectedHasPdf && ( - <> - {!showReadyPdf && ( - - - - View PDF - - - )} - - - - Download PDF - - - - )} - {canSkip && ( - <> - - handleSkip(selectedJob.id)} - className="text-destructive focus:text-destructive" - > - - Skip job - - - )} - - -
- setDetailTab(value as typeof detailTab)}> - - Overview - Tailoring - Description - - - - {selectedJob.suitabilityReason && ( -
- "{selectedJob.suitabilityReason}" -
- )} - -
-
-
Discipline
-
{selectedJob.disciplines || "-"}
-
-
-
Function
-
{selectedJob.jobFunction || "-"}
-
-
-
Level
-
{selectedJob.jobLevel || "-"}
-
-
-
Type
-
{selectedJob.jobType || "-"}
-
-
- -
- -
- -
-
-
- - - { - saveTailoringRef.current = save; - }} - onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })} - /> - - - -
-
- Job description -
-
- {!isEditingDescription ? ( - - ) : ( - <> - - - - )} - - - - - - { - void copyTextToClipboard(selectedJob.jobDescription || ""); - toast.success("Copied raw description"); - }} - > - - Copy raw text - - - -
-
- -
- {isEditingDescription ? ( -
-