tailoring editor confimration
This commit is contained in:
parent
b914026d8b
commit
f41609bd45
@ -95,7 +95,7 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
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;
|
||||
|
||||
@ -221,7 +221,7 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
{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);
|
||||
|
||||
@ -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<void>;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
onRegisterSave?: (save: () => Promise<void>) => void;
|
||||
onBeforeGenerate?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const TailoringEditor: React.FC<TailoringEditorProps> = ({ job, onUpdate }) => {
|
||||
export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
job,
|
||||
onUpdate,
|
||||
onDirtyChange,
|
||||
onRegisterSave,
|
||||
onBeforeGenerate,
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@ -22,6 +31,25 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({ 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<TailoringEditorProps> = ({ 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<TailoringEditorProps> = ({ 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<TailoringEditorProps> = ({ 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");
|
||||
|
||||
@ -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<null | (() => Promise<void>)>(null);
|
||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||
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 = () => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tailoring" className="pt-3">
|
||||
<TailoringEditor job={selectedJob} onUpdate={loadJobs} />
|
||||
<TailoringEditor
|
||||
job={selectedJob}
|
||||
onUpdate={loadJobs}
|
||||
onDirtyChange={setHasUnsavedTailoring}
|
||||
onRegisterSave={(save) => {
|
||||
saveTailoringRef.current = save;
|
||||
}}
|
||||
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="description" className="space-y-3 pt-3">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user