diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx new file mode 100644 index 0000000..9a3ebbf --- /dev/null +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -0,0 +1,417 @@ +/** + * ReadyPanel - Optimized "shipping lane" view for Ready jobs. + * + * Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied. + * The PDF is the primary artifact, represented abstractly through an Application Kit summary. + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + CheckCircle2, + ChevronUp, + Download, + ExternalLink, + FileText, + Loader2, + MoreHorizontal, + RefreshCcw, + Undo2, + Copy, + Edit2, + XCircle, + Briefcase, + Building2, + FolderKanban, + Sparkles, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { cn } from "@/lib/utils"; +import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; +import * as api from "../api"; +import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; + +interface ReadyPanelProps { + job: Job | null; + onJobUpdated: () => void | Promise; + onJobMoved: (jobId: string) => void; + onEditTailoring: () => void; + onEditDescription: () => void; +} + +const safeFilenamePart = (value: string | null | undefined) => + (value || "Unknown").replace(/[^\w\s-]/g, "").replace(/\s+/g, "_"); + +export const ReadyPanel: React.FC = ({ + job, + onJobUpdated, + onJobMoved, + onEditTailoring, + onEditDescription, +}) => { + const [isMarkingApplied, setIsMarkingApplied] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + const [catalog, setCatalog] = useState([]); + const [recentlyApplied, setRecentlyApplied] = useState<{ + jobId: string; + jobTitle: string; + employer: string; + timeoutId: ReturnType; + } | null>(null); + + // Load project catalog once + useEffect(() => { + api.getProfileProjects().then(setCatalog).catch(console.error); + }, []); + + // Compute derived values + const pdfHref = job + ? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}` + : "#"; + + const jobLink = job ? job.applicationLink || job.jobUrl : "#"; + + const selectedProjectIds = useMemo(() => { + return job?.selectedProjectIds?.split(",").filter(Boolean) ?? []; + }, [job?.selectedProjectIds]); + + const selectedProjectNames = useMemo(() => { + if (!catalog.length || !selectedProjectIds.length) return []; + return selectedProjectIds + .map(id => catalog.find(p => p.id === id)?.name) + .filter(Boolean) as string[]; + }, [catalog, selectedProjectIds]); + + const tailoredSummary = job?.tailoredSummary || null; + + // Handle mark as applied with undo capability + const handleMarkApplied = useCallback(async () => { + if (!job) return; + + try { + setIsMarkingApplied(true); + await api.markAsApplied(job.id); + + // Store for undo + const timeoutId = setTimeout(() => { + setRecentlyApplied(null); + }, 8000); + + setRecentlyApplied({ + jobId: job.id, + jobTitle: job.title, + employer: job.employer, + timeoutId, + }); + + // Notify parent to move to next job + onJobMoved(job.id); + await onJobUpdated(); + + toast.success("Marked as applied", { + description: `${job.title} at ${job.employer}`, + action: { + label: "Undo", + onClick: () => handleUndoApplied(job.id), + }, + duration: 6000, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to mark as applied"; + toast.error(message); + } finally { + setIsMarkingApplied(false); + } + }, [job, onJobMoved, onJobUpdated]); + + const handleUndoApplied = useCallback( + async (jobId: string) => { + try { + // Revert to ready status + await api.updateJob(jobId, { status: "ready" }); + toast.success("Reverted to Ready"); + + if (recentlyApplied?.timeoutId) { + clearTimeout(recentlyApplied.timeoutId); + } + setRecentlyApplied(null); + await onJobUpdated(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to undo"; + toast.error(message); + } + }, + [onJobUpdated, recentlyApplied], + ); + + const handleRegenerate = useCallback(async () => { + if (!job) return; + + try { + setIsRegenerating(true); + await api.generateJobPdf(job.id); + toast.success("PDF regenerated"); + await onJobUpdated(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to regenerate PDF"; + toast.error(message); + } finally { + setIsRegenerating(false); + } + }, [job, onJobUpdated]); + + const handleSkip = useCallback(async () => { + if (!job) return; + + try { + await api.skipJob(job.id); + toast.message("Job skipped"); + onJobMoved(job.id); + await onJobUpdated(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to skip"; + toast.error(message); + } + }, [job, onJobMoved, onJobUpdated]); + + const handleCopyInfo = useCallback(async () => { + if (!job) return; + + try { + await copyTextToClipboard(formatJobForWebhook(job)); + toast.success("Copied job info", { + description: "Webhook payload copied to clipboard.", + }); + } catch { + toast.error("Could not copy job info"); + } + }, [job]); + + // Empty state + if (!job) { + return ( +
+
+ +
+
No job selected
+

+ Select a Ready job to view its application kit and take action. +

+
+ ); + } + + return ( +
+ {/* ───────────────────────────────────────────────────────────────────── + PRIMARY ACTION CLUSTER + Three actions: Download PDF (primary), Open job (secondary), Mark applied (prominent) + ───────────────────────────────────────────────────────────────────── */} +
+ {/* Primary CTA: Mark Applied - most prominent, one-click */} + + + {/* Secondary actions row */} +
+ {/* Show PDF - to verify quickly without download */} + + + {/* Download PDF - primary artifact action */} + + + {/* Open job - to verify before applying */} + +
+
+ + {/* ───────────────────────────────────────────────────────────────────── + APPLICATION KIT SUMMARY + Abstract representation of what the PDF contains - verify at a glance + ───────────────────────────────────────────────────────────────────── */} +
+ {/* Job identity - confirm this is the right role */} +
+ {/* AI Suitability Reasoning - Why you're a fit */} + {job.suitabilityReason && ( +
+
+ + Fit Assessment +
+

+ {job.suitabilityReason} +

+
+ )} + + {/* Tailored summary snippet - shows what's in the PDF */} + {tailoredSummary && ( +
+
+ Tailored Summary +
+

+ "{tailoredSummary}" +

+
+ )} + + {/* Project selection - expandable accordion */} + + + +
+
+ +
+
+
+ {selectedProjectIds.length} {selectedProjectIds.length === 1 ? "project" : "projects"} selected +
+
+
+
+ +
    + {selectedProjectNames.map((name, i) => ( +
  • {name}
  • + ))} + {selectedProjectNames.length === 0 && ( +
  • No projects selected
  • + )} +
+
+
+
+
+
+ + {/* ───────────────────────────────────────────────────────────────────── + SECONDARY ACTIONS + Fix/More menu - all non-critical actions demoted here + ───────────────────────────────────────────────────────────────────── */} +
+ + + + + + {/* Fix/Edit actions */} + + + Edit tailoring + + + + + {isRegenerating ? "Regenerating..." : "Regenerate PDF"} + + + + + Edit job description + + + + + {/* Utility actions */} + + + Copy job info + + + + + {/* Destructive actions */} + + + Skip this job + + + +
+ + {/* ───────────────────────────────────────────────────────────────────── + UNDO BAR (conditional) + Lightweight undo option after marking applied + ───────────────────────────────────────────────────────────────────── */} + {recentlyApplied && ( +
+
+ + + {recentlyApplied.jobTitle} + marked applied + + +
+
+ )} +
+ ); +}; diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index c4e6b8e..76fdba3 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -8,4 +8,5 @@ export { JobList } from './JobList'; export { PipelineProgress } from './PipelineProgress'; export { TailoringEditor } from './TailoringEditor'; export { DiscoveredPanel } from './DiscoveredPanel'; +export { ReadyPanel } from './ReadyPanel'; export * from './layout'; diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 290b681..78fee8d 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -50,6 +50,7 @@ import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; import { PipelineProgress, DiscoveredPanel } from "../components"; +import { ReadyPanel } from "../components/ReadyPanel"; import * as api from "../api"; import { TailoringEditor } from "../components/TailoringEditor"; import type { Job, JobSource, JobStatus } from "../../shared/types"; @@ -913,6 +914,30 @@ export const OrchestratorPage: React.FC = () => { 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