/** * DiscoveredPanel - Two-mode triage workspace for Discovered jobs. * * Mode A: Decide (default) - Quick assessment to Skip or Tailor * Mode B: Tailor - Draft tailoring data before moving to Ready * * Moving to Ready generates the PDF using the current tailored draft. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { AlertTriangle, ArrowLeft, Calendar, Check, ChevronDown, ChevronUp, DollarSign, ExternalLink, Loader2, MapPin, Sparkles, 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 { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; import { formatDate } from "../lib/dateUtils"; import * as api from "../api"; import { FitAssessment } from "."; import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── type PanelMode = "decide" | "tailor"; interface DiscoveredPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── const stripHtml = (value: string) => value .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") .trim(); const sourceLabel: Record = { gradcracker: "Gradcracker", indeed: "Indeed", linkedin: "LinkedIn", ukvisajobs: "UK Visa Jobs", manual: "Manual", }; // ───────────────────────────────────────────────────────────────────────────── // Decide Mode Panel // ───────────────────────────────────────────────────────────────────────────── interface DecideModeProps { job: Job; onTailor: () => void; onSkip: () => void; isSkipping: boolean; } const DecideMode: React.FC = ({ job, onTailor, onSkip, isSkipping, }) => { const [showDescription, setShowDescription] = useState(false); const deadline = formatDate(job.deadline); const jobLink = job.applicationLink || job.jobUrl; const description = useMemo(() => { if (!job.jobDescription) return "No description available."; const jd = job.jobDescription; if (jd.includes("<") && jd.includes(">")) return stripHtml(jd); return jd; }, [job.jobDescription]); return (
{/* Header */}

{job.title}

{job.employer}

{sourceLabel[job.source]}
{/* Metadata row */}
{job.location && ( {job.location} )} {deadline && ( {deadline} )} {job.salary && ( {job.salary} )}
{/* Primary/Secondary actions */}
{/* Fit Summary - the core content */}
{/* Collapsible full description */}
{showDescription && (

{description}

)}
{/* Actions - clear hierarchy */}
{/* External link - tertiary */}
); }; // ───────────────────────────────────────────────────────────────────────────── // Tailor Mode Panel // ───────────────────────────────────────────────────────────────────────────── interface TailorModeProps { job: Job; onBack: () => void; onFinalize: () => void; isFinalizing: boolean; } const TailorMode: React.FC = ({ job, onBack, onFinalize, isFinalizing, }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); const [selectedIds, setSelectedIds] = useState>(() => { const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; return new Set(saved); }); const [isGenerating, setIsGenerating] = useState(false); const [isSaving, setIsSaving] = useState(false); const [draftStatus, setDraftStatus] = useState< "unsaved" | "saving" | "saved" >("saved"); const [showDescription, setShowDescription] = useState(false); // Load project catalog useEffect(() => { api.getProfileProjects().then(setCatalog).catch(console.error); }, []); // Reset form when job changes useEffect(() => { setSummary(job.tailoredSummary || ""); setJobDescription(job.jobDescription || ""); const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; setSelectedIds(new Set(saved)); setDraftStatus("saved"); }, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]); // Track unsaved changes const savedSummary = job.tailoredSummary || ""; const savedDescription = job.jobDescription || ""; const savedIds = useMemo(() => { const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; return new Set(saved); }, [job.selectedProjectIds]); const hasChanges = useMemo(() => { if (summary !== savedSummary) return true; if (jobDescription !== savedDescription) return true; if (selectedIds.size !== savedIds.size) return true; for (const id of selectedIds) { if (!savedIds.has(id)) return true; } return false; }, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]); // Update draft status when changes are made useEffect(() => { if (hasChanges && draftStatus === "saved") { setDraftStatus("unsaved"); } }, [hasChanges, draftStatus]); // Auto-save draft (debounced) useEffect(() => { if (!hasChanges || draftStatus !== "unsaved") return; const timeout = setTimeout(async () => { try { setDraftStatus("saving"); await api.updateJob(job.id, { tailoredSummary: summary, jobDescription: jobDescription, selectedProjectIds: Array.from(selectedIds).join(","), }); setDraftStatus("saved"); } catch { setDraftStatus("unsaved"); } }, 1500); return () => clearTimeout(timeout); }, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]); const handleToggleProject = (id: string) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); setSelectedIds(next); }; const handleGenerateWithAI = async () => { try { setIsGenerating(true); // Save any pending changes first so AI uses the latest description if (hasChanges) { await api.updateJob(job.id, { tailoredSummary: summary, jobDescription: jobDescription, selectedProjectIds: Array.from(selectedIds).join(","), }); } const updatedJob = await api.summarizeJob(job.id, { force: true }); setSummary(updatedJob.tailoredSummary || ""); setJobDescription(updatedJob.jobDescription || ""); if (updatedJob.selectedProjectIds) { setSelectedIds( new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)) ); } setDraftStatus("saved"); // AI response is saved server-side toast.success("Draft generated with AI", { description: "Review and edit before finalizing.", }); } catch { toast.error("Failed to generate AI draft"); } finally { setIsGenerating(false); } }; const handleFinalize = async () => { // Save any pending changes first if (hasChanges) { try { setIsSaving(true); await api.updateJob(job.id, { tailoredSummary: summary, jobDescription: jobDescription, selectedProjectIds: Array.from(selectedIds).join(","), }); } catch { toast.error("Failed to save draft before finalizing"); setIsSaving(false); return; } finally { setIsSaving(false); } } // Now finalize (which generates PDF and moves to Ready) onFinalize(); }; const maxProjects = 3; const tooManyProjects = selectedIds.size > maxProjects; const canFinalize = summary.trim().length > 0 && selectedIds.size > 0; return (
{/* Header with back navigation */}
{/* Draft status indicator */}
{draftStatus === "saving" && ( <> Saving... )} {draftStatus === "saved" && !hasChanges && ( <> Saved )} {draftStatus === "unsaved" && ( Unsaved changes )}
{/* Draft framing */}
Draft tailoring for this role

Edit below, then finalize to generate your PDF and move to Ready.

{/* Scrollable content */}
{/* AI Generate option */}
Need help getting started?
AI can draft a summary and select projects for you
{/* Job Description - collapsible */}
{showDescription && (