diff --git a/orchestrator/src/client/components/JobCard.tsx b/orchestrator/src/client/components/JobCard.tsx index 819b2be..612d846 100644 --- a/orchestrator/src/client/components/JobCard.tsx +++ b/orchestrator/src/client/components/JobCard.tsx @@ -95,7 +95,7 @@ export const JobCard: React.FC = ({ const canReject = ["discovered", "ready"].includes(job.status); const jobLink = job.applicationLink || job.jobUrl; - const pdfHref = `/pdfs/resume_${job.id}.pdf`; + const pdfHref = `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`; const deadline = formatDate(job.deadline); const discoveredAt = formatDateTime(job.discoveredAt); const isHighlighted = highlightedJobId === job.id; diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx index a77bf9b..0c40940 100644 --- a/orchestrator/src/client/components/JobTable.tsx +++ b/orchestrator/src/client/components/JobTable.tsx @@ -221,7 +221,7 @@ export const JobTable: React.FC = ({ {jobs.map((job) => { const jobLink = job.applicationLink || job.jobUrl; const hasPdf = !!job.pdfPath; - const pdfHref = `/pdfs/resume_${job.id}.pdf`; + const pdfHref = `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`; const canApply = job.status === "ready"; const canProcess = ["discovered", "ready"].includes(job.status); diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx index 03cc661..7b6346b 100644 --- a/orchestrator/src/client/components/TailoringEditor.tsx +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; @@ -12,9 +12,18 @@ import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; interface TailoringEditorProps { job: Job; onUpdate: () => void | Promise; + onDirtyChange?: (isDirty: boolean) => void; + onRegisterSave?: (save: () => Promise) => void; + onBeforeGenerate?: () => boolean | Promise; } -export const TailoringEditor: React.FC = ({ job, onUpdate }) => { +export const TailoringEditor: React.FC = ({ + job, + onUpdate, + onDirtyChange, + onRegisterSave, + onBeforeGenerate, +}) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -22,6 +31,25 @@ export const TailoringEditor: React.FC = ({ job, onUpdate const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isSaving, setIsSaving] = useState(false); + const savedSelectedIds = useMemo(() => { + const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; + return new Set(saved); + }, [job.selectedProjectIds]); + + const hasSelectionDiff = useMemo(() => { + if (selectedIds.size !== savedSelectedIds.size) return true; + for (const id of selectedIds) { + if (!savedSelectedIds.has(id)) return true; + } + return false; + }, [selectedIds, savedSelectedIds]); + + const isDirty = summary !== (job.tailoredSummary || "") || hasSelectionDiff; + + useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + useEffect(() => { // Load project catalog api.getProfileProjects().then(setCatalog).catch(console.error); @@ -36,6 +64,30 @@ export const TailoringEditor: React.FC = ({ job, onUpdate setSummary(job.tailoredSummary || ""); }, [job.tailoredSummary]); + const saveChanges = useCallback( + async ({ showToast = true }: { showToast?: boolean } = {}) => { + try { + setIsSaving(true); + await api.updateJob(job.id, { + tailoredSummary: summary, + selectedProjectIds: Array.from(selectedIds).join(","), + }); + if (showToast) toast.success("Changes saved"); + await onUpdate(); + } catch (error) { + if (showToast) toast.error("Failed to save changes"); + throw error; + } finally { + setIsSaving(false); + } + }, + [job.id, onUpdate, selectedIds, summary], + ); + + useEffect(() => { + onRegisterSave?.(() => saveChanges({ showToast: false })); + }, [onRegisterSave, saveChanges]); + const handleToggleProject = (id: string) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); @@ -45,17 +97,9 @@ export const TailoringEditor: React.FC = ({ job, onUpdate const handleSave = async () => { try { - setIsSaving(true); - await api.updateJob(job.id, { - tailoredSummary: summary, - selectedProjectIds: Array.from(selectedIds).join(','), - }); - toast.success("Changes saved"); - await onUpdate(); - } catch (error) { - toast.error("Failed to save changes"); - } finally { - setIsSaving(false); + await saveChanges(); + } catch { + // Toast handled in saveChanges } }; @@ -78,12 +122,12 @@ export const TailoringEditor: React.FC = ({ job, onUpdate const handleGeneratePdf = async () => { try { + const shouldProceed = onBeforeGenerate ? await onBeforeGenerate() : true; + if (shouldProceed === false) return; + setIsGeneratingPdf(true); // Save current state first to ensure PDF uses latest - await api.updateJob(job.id, { - tailoredSummary: summary, - selectedProjectIds: Array.from(selectedIds).join(','), - }); + await saveChanges({ showToast: false }); await api.generateJobPdf(job.id); toast.success("Resume PDF generated"); diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index dc25bce..b01f742 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -2,7 +2,7 @@ * Orchestrator layout with a split list/detail experience. */ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowUpDown, Calendar, @@ -304,6 +304,8 @@ export const OrchestratorPage: React.FC = () => { const [isEditingDescription, setIsEditingDescription] = useState(false); const [editedDescription, setEditedDescription] = useState(""); const [isSavingDescription, setIsSavingDescription] = useState(false); + const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false); + const saveTailoringRef = useRef Promise)>(null); const [pipelineSources, setPipelineSources] = useState(() => { try { const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); @@ -390,11 +392,21 @@ export const OrchestratorPage: React.FC = () => { 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"); + 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"; @@ -472,6 +484,11 @@ export const OrchestratorPage: React.FC = () => { [jobs, selectedJobId], ); + useEffect(() => { + setHasUnsavedTailoring(false); + saveTailoringRef.current = null; + }, [selectedJob?.id]); + const description = useMemo(() => { if (!selectedJob?.jobDescription) return "No description available."; const jd = selectedJob.jobDescription; @@ -512,11 +529,56 @@ export const OrchestratorPage: React.FC = () => { } }; + 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` : "#"; + 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"; @@ -1024,7 +1086,15 @@ export const OrchestratorPage: React.FC = () => { - + { + saveTailoringRef.current = save; + }} + onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })} + />