tailoring editor confimration
This commit is contained in:
@@ -95,7 +95,7 @@ export const JobCard: React.FC<JobCardProps> = ({
|
|||||||
const canReject = ["discovered", "ready"].includes(job.status);
|
const canReject = ["discovered", "ready"].includes(job.status);
|
||||||
|
|
||||||
const jobLink = job.applicationLink || job.jobUrl;
|
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 deadline = formatDate(job.deadline);
|
||||||
const discoveredAt = formatDateTime(job.discoveredAt);
|
const discoveredAt = formatDateTime(job.discoveredAt);
|
||||||
const isHighlighted = highlightedJobId === job.id;
|
const isHighlighted = highlightedJobId === job.id;
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const JobTable: React.FC<JobTableProps> = ({
|
|||||||
{jobs.map((job) => {
|
{jobs.map((job) => {
|
||||||
const jobLink = job.applicationLink || job.jobUrl;
|
const jobLink = job.applicationLink || job.jobUrl;
|
||||||
const hasPdf = !!job.pdfPath;
|
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 canApply = job.status === "ready";
|
||||||
const canProcess = ["discovered", "ready"].includes(job.status);
|
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 { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -12,9 +12,18 @@ import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
|||||||
interface TailoringEditorProps {
|
interface TailoringEditorProps {
|
||||||
job: Job;
|
job: Job;
|
||||||
onUpdate: () => void | Promise<void>;
|
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 [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
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 [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||||
const [isSaving, setIsSaving] = 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(() => {
|
useEffect(() => {
|
||||||
// Load project catalog
|
// Load project catalog
|
||||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||||
@@ -36,6 +64,30 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({ job, onUpdate
|
|||||||
setSummary(job.tailoredSummary || "");
|
setSummary(job.tailoredSummary || "");
|
||||||
}, [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 handleToggleProject = (id: string) => {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -45,17 +97,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({ job, onUpdate
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
await saveChanges();
|
||||||
await api.updateJob(job.id, {
|
} catch {
|
||||||
tailoredSummary: summary,
|
// Toast handled in saveChanges
|
||||||
selectedProjectIds: Array.from(selectedIds).join(','),
|
|
||||||
});
|
|
||||||
toast.success("Changes saved");
|
|
||||||
await onUpdate();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to save changes");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,12 +122,12 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({ job, onUpdate
|
|||||||
|
|
||||||
const handleGeneratePdf = async () => {
|
const handleGeneratePdf = async () => {
|
||||||
try {
|
try {
|
||||||
|
const shouldProceed = onBeforeGenerate ? await onBeforeGenerate() : true;
|
||||||
|
if (shouldProceed === false) return;
|
||||||
|
|
||||||
setIsGeneratingPdf(true);
|
setIsGeneratingPdf(true);
|
||||||
// Save current state first to ensure PDF uses latest
|
// Save current state first to ensure PDF uses latest
|
||||||
await api.updateJob(job.id, {
|
await saveChanges({ showToast: false });
|
||||||
tailoredSummary: summary,
|
|
||||||
selectedProjectIds: Array.from(selectedIds).join(','),
|
|
||||||
});
|
|
||||||
|
|
||||||
await api.generateJobPdf(job.id);
|
await api.generateJobPdf(job.id);
|
||||||
toast.success("Resume PDF generated");
|
toast.success("Resume PDF generated");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Orchestrator layout with a split list/detail experience.
|
* 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 {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -304,6 +304,8 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||||
const [editedDescription, setEditedDescription] = useState("");
|
const [editedDescription, setEditedDescription] = useState("");
|
||||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||||
|
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
||||||
|
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
||||||
@@ -390,11 +392,21 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleProcess = async (jobId: string) => {
|
const handleProcess = async (jobId: string) => {
|
||||||
try {
|
try {
|
||||||
setProcessingJobId(jobId);
|
|
||||||
const job = jobs.find((item) => item.id === jobId);
|
const job = jobs.find((item) => item.id === jobId);
|
||||||
const force = job?.status === "ready";
|
if (!job) throw new Error("Job not found");
|
||||||
await api.processJob(jobId, { force });
|
|
||||||
toast.success(force ? "Resume regenerated successfully" : "Resume generated successfully");
|
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();
|
await loadJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to process job";
|
const message = error instanceof Error ? error.message : "Failed to process job";
|
||||||
@@ -472,6 +484,11 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
[jobs, selectedJobId],
|
[jobs, selectedJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasUnsavedTailoring(false);
|
||||||
|
saveTailoringRef.current = null;
|
||||||
|
}, [selectedJob?.id]);
|
||||||
|
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
if (!selectedJob?.jobDescription) return "No description available.";
|
if (!selectedJob?.jobDescription) return "No description available.";
|
||||||
const jd = selectedJob.jobDescription;
|
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 totalJobs = Object.values(stats).reduce((a, b) => a + b, 0);
|
||||||
const activeResultsCount = activeJobs.length;
|
const activeResultsCount = activeJobs.length;
|
||||||
const selectedHasPdf = !!selectedJob?.pdfPath;
|
const selectedHasPdf = !!selectedJob?.pdfPath;
|
||||||
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
|
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 selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null;
|
||||||
const selectedDiscoveredAt = selectedJob ? formatDateTime(selectedJob.discoveredAt) : null;
|
const selectedDiscoveredAt = selectedJob ? formatDateTime(selectedJob.discoveredAt) : null;
|
||||||
const canApply = selectedJob?.status === "ready";
|
const canApply = selectedJob?.status === "ready";
|
||||||
@@ -1024,7 +1086,15 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="tailoring" className="pt-3">
|
<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>
|
||||||
|
|
||||||
<TabsContent value="description" className="space-y-3 pt-3">
|
<TabsContent value="description" className="space-y-3 pt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user