Add CV template preview and PDF export pipeline
This commit is contained in:
@@ -19,9 +19,10 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
@@ -47,6 +48,12 @@ type PackageWorkspaceState = {
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
type TailoredCvPreviewResponse = {
|
||||
templateId: string;
|
||||
html: string;
|
||||
suggestedFileName: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
jobId: number | null;
|
||||
@@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) {
|
||||
return { label: "Empty", color: "default" as const };
|
||||
}
|
||||
|
||||
function serializeTailoredDraft(draft: TailoredCvDraft) {
|
||||
const normalized = normalizeTailoredCvDraft(draft);
|
||||
return JSON.stringify({
|
||||
templateId: normalized.templateId,
|
||||
headline: normalized.headline ?? "",
|
||||
summary: normalized.summary,
|
||||
selectedSkills: normalized.selectedSkills,
|
||||
experience: normalized.experience,
|
||||
education: normalized.education,
|
||||
customSections: normalized.customSections,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
isLegacyFallback: normalized.isLegacyFallback,
|
||||
});
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
||||
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
|
||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||
const [tailoredCvDraft, setTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
|
||||
const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
|
||||
const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false);
|
||||
const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false);
|
||||
const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false);
|
||||
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
|
||||
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
|
||||
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
|
||||
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
|
||||
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
|
||||
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
|
||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
@@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setPackageGeneratedAt(null);
|
||||
setTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setSavedTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setTailoredCvPreview(null);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
setCustomPhotoDataUrl(null);
|
||||
setUseProfilePhoto(true);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
@@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
});
|
||||
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
|
||||
api.get(`/auth/me`).then((r) => {
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||
}).catch(() => {
|
||||
setIsAdmin(false);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
});
|
||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 3) return;
|
||||
setLoadingTailoredCvDraft(true);
|
||||
api.get<TailoredCvDraft>(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => {
|
||||
const normalized = normalizeTailoredCvDraft(r.data);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
}).catch(() => {
|
||||
const empty = emptyTailoredCvDraft();
|
||||
setTailoredCvDraft(empty);
|
||||
setSavedTailoredCvDraft(empty);
|
||||
}).finally(() => setLoadingTailoredCvDraft(false));
|
||||
}, [open, jobId, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
setLoadingDraft(true);
|
||||
@@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
|
||||
const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText);
|
||||
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
|
||||
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
|
||||
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
|
||||
const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft);
|
||||
const hasUnsavedPackageChanges = [
|
||||
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
|
||||
].some(Boolean);
|
||||
|
||||
const saveTailoredCvDraft = async () => {
|
||||
if (!jobId) return;
|
||||
|
||||
const normalized = normalizeTailoredCvDraft({
|
||||
...tailoredCvDraft,
|
||||
status: tailoredCvDraft.status === "empty" ? "edited" : tailoredCvDraft.status,
|
||||
});
|
||||
|
||||
try {
|
||||
setSavingTailoredCvDraft(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv-draft`, {
|
||||
templateId: normalized.templateId,
|
||||
headline: normalized.headline,
|
||||
summary: normalized.summary,
|
||||
selectedSkills: normalized.selectedSkills,
|
||||
experience: normalized.experience,
|
||||
education: normalized.education,
|
||||
customSections: normalized.customSections,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
});
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
setReadiness(null);
|
||||
toast("Tailored CV draft saved.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the tailored CV draft."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCvDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateTailoredCvDraft = async () => {
|
||||
if (!jobId) return;
|
||||
if (hasUnsavedTailoredCvDraftChanges) {
|
||||
const confirmed = await confirmAction("Regenerating the tailored CV draft will replace your unsaved edits.", {
|
||||
title: "Replace unsaved tailored CV edits?",
|
||||
confirmLabel: "Regenerate draft",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGeneratingTailoredCvDraft(true);
|
||||
const res = await api.post<TailoredCvDraft>(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } });
|
||||
const normalized = normalizeTailoredCvDraft(res.data);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
toast("Tailored CV draft generated.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to generate a tailored CV draft."), "error");
|
||||
} finally {
|
||||
setGeneratingTailoredCvDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetTailoredCvDraftToSaved = () => {
|
||||
setTailoredCvDraft(savedTailoredCvDraft);
|
||||
toast("Restored the last saved tailored CV draft.", "info");
|
||||
};
|
||||
|
||||
const buildTailoredCvRenderPayload = () => ({
|
||||
templateId: tailoredCvDraft.templateId,
|
||||
headline: tailoredCvDraft.headline,
|
||||
summary: tailoredCvDraft.summary,
|
||||
selectedSkills: tailoredCvDraft.selectedSkills,
|
||||
experience: tailoredCvDraft.experience,
|
||||
education: tailoredCvDraft.education,
|
||||
customSections: tailoredCvDraft.customSections,
|
||||
renderOptions: tailoredCvDraft.renderOptions,
|
||||
photoDataUrl: customPhotoDataUrl,
|
||||
useProfileAvatar: useProfilePhoto,
|
||||
});
|
||||
|
||||
const refreshTailoredCvPreview = async () => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
setLoadingTailoredCvPreview(true);
|
||||
const res = await api.post<TailoredCvPreviewResponse>(`/jobapplications/${jobId}/tailored-cv-preview`, buildTailoredCvRenderPayload());
|
||||
setTailoredCvPreview(res.data);
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to build the CV preview."), "error");
|
||||
} finally {
|
||||
setLoadingTailoredCvPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportTailoredCvPdf = async () => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
setExportingTailoredCvPdf(true);
|
||||
const response = await api.post(`/jobapplications/${jobId}/export-tailored-cv-pdf`, buildTailoredCvRenderPayload(), { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = tailoredCvPreview?.suggestedFileName || `${(job?.jobTitle ?? "tailored-cv").replace(/\s+/g, "-").toLowerCase()}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast("Tailored CV PDF downloaded.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to export the CV PDF."), "error");
|
||||
} finally {
|
||||
setExportingTailoredCvPdf(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePackageWorkspace = async () => {
|
||||
if (!jobId || !job) return;
|
||||
|
||||
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
|
||||
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
|
||||
const draftsChanged =
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
|
||||
|
||||
if (!tailoredCvChanged && !draftsChanged) {
|
||||
if (!draftsChanged) {
|
||||
toast("No unsaved package changes.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (tailoredCvChanged) {
|
||||
setSavingTailoredCv(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
}
|
||||
|
||||
if (draftsChanged) {
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
}
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText,
|
||||
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
notes: nextNotes,
|
||||
@@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPackageWorkspaceToSaved = () => {
|
||||
setTailoredCvText(job?.tailoredCvText ?? "");
|
||||
setPackageWorkspace(savedPackageWorkspace);
|
||||
toast("Restored the last saved package.", "info");
|
||||
};
|
||||
@@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 3 && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedTailoredCvDraftChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
|
||||
<Typography variant="overline">Tailored CV draft</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>This draft is job-scoped. It stays separate from your master CV and from the package drafts below.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
@@ -506,6 +670,214 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Template</InputLabel>
|
||||
<Select value={tailoredCvDraft.templateId} label="Template" onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, templateId: e.target.value, status: "edited" }))}>
|
||||
<MenuItem value="ats-minimal">ATS Minimal</MenuItem>
|
||||
<MenuItem value="harvard">Harvard</MenuItem>
|
||||
<MenuItem value="auckland">Auckland</MenuItem>
|
||||
<MenuItem value="edinburgh">Edinburgh</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Accent"
|
||||
type="color"
|
||||
value={tailoredCvDraft.renderOptions.accentColor?.startsWith("#") ? tailoredCvDraft.renderOptions.accentColor : "#334155"}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
renderOptions: { ...current.renderOptions, accentColor: e.target.value },
|
||||
status: "edited",
|
||||
}))}
|
||||
sx={{ width: 110 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button size="small" variant={tailoredCvDraft.renderOptions.showPhoto ? "contained" : "outlined"} onClick={() => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
renderOptions: { ...current.renderOptions, showPhoto: !current.renderOptions.showPhoto },
|
||||
status: "edited",
|
||||
}))}>{tailoredCvDraft.renderOptions.showPhoto ? "Photo on" : "Photo off"}</Button>
|
||||
<Button size="small" variant={useProfilePhoto ? "contained" : "outlined"} onClick={() => setUseProfilePhoto((current) => !current)}>{useProfilePhoto ? "Using profile photo" : "Profile photo off"}</Button>
|
||||
<Button size="small" variant="outlined" component="label">
|
||||
Pick photo
|
||||
<input hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setCustomPhotoDataUrl(typeof reader.result === "string" ? reader.result : null);
|
||||
reader.readAsDataURL(file);
|
||||
}} />
|
||||
</Button>
|
||||
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
|
||||
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCvDraft || loadingTailoredCvDraft} onClick={saveTailoredCvDraft}>{savingTailoredCvDraft ? t("jobDetailsSaving") : "Save tailored draft"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Chip size="small" label={`Tailored CV · ${tailoredCvDraftStatus.label}`} color={tailoredCvDraftStatus.color} />
|
||||
<Chip size="small" variant="outlined" label={`Template · ${tailoredCvDraft.templateId}`} />
|
||||
{tailoredCvDraft.isLegacyFallback ? <Chip size="small" color="warning" variant="outlined" label="Legacy text fallback" /> : null}
|
||||
{tailoredCvDraft.lastGeneratedAtUtc ? <Chip size="small" variant="outlined" label={`Generated ${new Date(tailoredCvDraft.lastGeneratedAtUtc).toLocaleString()}`} /> : null}
|
||||
{tailoredCvDraft.canonicalProfileVersion ? <Chip size="small" variant="outlined" label={`Profile v${tailoredCvDraft.canonicalProfileVersion}`} /> : null}
|
||||
</Box>
|
||||
|
||||
{loadingTailoredCvDraft ? (
|
||||
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||
) : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.1fr 0.9fr" }, gap: 2 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TextField
|
||||
label="Headline"
|
||||
value={tailoredCvDraft.headline ?? ""}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Summary bullets"
|
||||
value={joinLines(tailoredCvDraft.summary)}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))}
|
||||
multiline
|
||||
minRows={5}
|
||||
fullWidth
|
||||
helperText="One bullet per line."
|
||||
/>
|
||||
<TextField
|
||||
label="Selected skills"
|
||||
value={joinLines(tailoredCvDraft.selectedSkills)}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))}
|
||||
multiline
|
||||
minRows={4}
|
||||
fullWidth
|
||||
helperText="One skill per line."
|
||||
/>
|
||||
<TextField
|
||||
label="Experience"
|
||||
value={tailoredCvDraft.experience.map((item) => [
|
||||
[item.title, item.company].filter(Boolean).join(" — "),
|
||||
[item.location, item.start, item.end].filter(Boolean).join(" | "),
|
||||
...(item.bullets ?? []).map((bullet) => `- ${bullet}`),
|
||||
].filter(Boolean).join("\n")).join("\n\n")}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
experience: e.target.value
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const [titleCompany = "", meta = "", ...bulletLines] = lines;
|
||||
const [title = "", company = ""] = titleCompany.split("—").map((part) => part.trim());
|
||||
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
|
||||
return {
|
||||
title,
|
||||
company,
|
||||
location,
|
||||
start,
|
||||
end,
|
||||
bullets: bulletLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
|
||||
};
|
||||
}),
|
||||
status: "edited",
|
||||
}))}
|
||||
multiline
|
||||
minRows={10}
|
||||
fullWidth
|
||||
helperText="Separate entries with a blank line. First line: Title — Company. Second line: Location | Start | End."
|
||||
/>
|
||||
<TextField
|
||||
label="Education"
|
||||
value={tailoredCvDraft.education.map((item) => [
|
||||
[item.qualification, item.institution].filter(Boolean).join(" — "),
|
||||
[item.location, item.start, item.end].filter(Boolean).join(" | "),
|
||||
...(item.details ?? []).map((detail) => `- ${detail}`),
|
||||
].filter(Boolean).join("\n")).join("\n\n")}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
education: e.target.value
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const [qualificationInstitution = "", meta = "", ...detailLines] = lines;
|
||||
const [qualification = "", institution = ""] = qualificationInstitution.split("—").map((part) => part.trim());
|
||||
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
|
||||
return {
|
||||
qualification,
|
||||
institution,
|
||||
location,
|
||||
start,
|
||||
end,
|
||||
details: detailLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
|
||||
};
|
||||
}),
|
||||
status: "edited",
|
||||
}))}
|
||||
multiline
|
||||
minRows={8}
|
||||
fullWidth
|
||||
helperText="Separate entries with a blank line. First line: Qualification — Institution. Second line: Location | Start | End."
|
||||
/>
|
||||
<TextField
|
||||
label="Custom sections"
|
||||
value={tailoredCvDraft.customSections.map((section) => `${section.title || "Additional Information"}\n${(section.items ?? []).join("\n")}`).join("\n\n")}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
customSections: e.target.value
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const [title = "", ...items] = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return { title, items };
|
||||
}),
|
||||
status: "edited",
|
||||
}))}
|
||||
multiline
|
||||
minRows={7}
|
||||
fullWidth
|
||||
helperText="Each block starts with the section title, followed by one item per line."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">Rendered CV snapshot</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft.</Typography>
|
||||
<TextField value={tailoredCvDraft.renderedText} multiline minRows={12} fullWidth InputProps={{ readOnly: true }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">PDF-style preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here.</Typography>
|
||||
{tailoredCvPreview ? (
|
||||
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>Build the PDF layout preview to inspect the ATS template before downloading.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">Saved job material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Saving the tailored draft updates the job-scoped CV text without touching your master profile.</Typography>
|
||||
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Master CV:</strong> Never overwritten here</Typography>
|
||||
<Typography variant="body2"><strong>Photo source:</strong> {customPhotoDataUrl ? "Custom preview photo" : useProfilePhoto && profileAvatarImageDataUrl ? "Profile picture" : "No photo source selected"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">Application package drafts</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>These drafts stay separate from the tailored CV draft. Save them when you want reusable role-specific copy on the job.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
|
||||
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
|
||||
@@ -515,22 +887,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button size="small" variant="outlined" onClick={async () => {
|
||||
try {
|
||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
||||
setTailoredCvText(me.data?.profileCvText ?? "");
|
||||
toast(t("jobDetailsLoadedMasterCv"), "success");
|
||||
} catch {
|
||||
toast(t("jobDetailsLoadMasterCvFailed"), "error");
|
||||
}
|
||||
}}>{t("jobDetailsStartFromMasterCv")}</Button>
|
||||
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setGeneratingPackage(true);
|
||||
try {
|
||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
|
||||
setApplicationPackage(res.data);
|
||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||
setPackageWorkspace({
|
||||
coverLetter: res.data.coverLetterDraft ?? "",
|
||||
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
||||
@@ -545,58 +907,53 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}
|
||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button>
|
||||
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
|
||||
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
|
||||
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
|
||||
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
|
||||
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
|
||||
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
|
||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Typography variant="overline">Saved working material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">Saved working material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
|
||||
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||
</Box>
|
||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
|
||||
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,10 @@ beforeEach(() => {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
Object.assign(URL, {
|
||||
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
|
||||
revokeObjectURL: jest.fn(),
|
||||
});
|
||||
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
@@ -61,7 +65,7 @@ beforeEach(() => {
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/history') {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
@@ -69,6 +73,26 @@ beforeEach(() => {
|
||||
if (url === '/attachments/42') {
|
||||
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/tailored-cv-draft') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 5,
|
||||
canonicalProfileVersion: 3,
|
||||
templateId: 'ats-minimal',
|
||||
headline: 'Backend Engineer',
|
||||
summary: ['Built APIs', 'Shipped backend work'],
|
||||
selectedSkills: ['.NET', 'SQL'],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
|
||||
generationContextHash: 'abc123',
|
||||
lastGeneratedAtUtc: new Date().toISOString(),
|
||||
lastEditedAtUtc: null,
|
||||
status: 'generated',
|
||||
renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work',
|
||||
isLegacyFallback: false,
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/candidate-fit') {
|
||||
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
|
||||
}
|
||||
@@ -77,7 +101,44 @@ beforeEach(() => {
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
||||
|
||||
mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => {
|
||||
if (url === '/jobapplications/42/generate-tailored-cv-draft') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 5,
|
||||
canonicalProfileVersion: 3,
|
||||
templateId: 'ats-minimal',
|
||||
headline: 'Senior Backend Engineer',
|
||||
summary: ['Owned API delivery', 'Improved SQL workflows'],
|
||||
selectedSkills: ['.NET', 'SQL', 'APIs'],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
|
||||
generationContextHash: 'def456',
|
||||
lastGeneratedAtUtc: new Date().toISOString(),
|
||||
lastEditedAtUtc: null,
|
||||
status: 'generated',
|
||||
renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows',
|
||||
isLegacyFallback: false,
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/tailored-cv-preview') {
|
||||
return Promise.resolve({ data: {
|
||||
templateId: body?.templateId ?? 'ats-minimal',
|
||||
suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`,
|
||||
html: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></html>`,
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/export-tailored-cv-pdf') {
|
||||
return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/generate-application-package') {
|
||||
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
@@ -85,20 +146,40 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
|
||||
test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
|
||||
expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate application package/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument();
|
||||
const headline = screen.getByDisplayValue('Senior Backend Engineer');
|
||||
fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({
|
||||
headline: 'Principal Backend Engineer',
|
||||
summary: ['Owned API delivery', 'Improved SQL workflows'],
|
||||
selectedSkills: ['.NET', 'SQL', 'APIs'],
|
||||
status: 'edited',
|
||||
}));
|
||||
});
|
||||
|
||||
expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything());
|
||||
});
|
||||
|
||||
test('application package drafts save separately from the tailored cv draft', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /generate application package/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
|
||||
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
||||
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
||||
@@ -107,18 +188,59 @@ test('application package workspace reflects saved job material, generated draft
|
||||
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
||||
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||
coverLetterText: 'Edited cover letter',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
recruiterMessageDraft: 'Edited recruiter note',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
|
||||
test('template switching refreshes preview and export uses the selected template payload', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
|
||||
const comboboxes = await screen.findAllByRole('combobox');
|
||||
fireEvent.mouseDown(comboboxes[1]);
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Harvard' }));
|
||||
|
||||
const accent = screen.getByLabelText('Accent');
|
||||
fireEvent.change(accent, { target: { value: '#123456' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({
|
||||
templateId: 'harvard',
|
||||
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
|
||||
useProfileAvatar: true,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument();
|
||||
|
||||
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
|
||||
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
|
||||
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /download pdf/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({
|
||||
templateId: 'harvard',
|
||||
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
|
||||
}), expect.objectContaining({ responseType: 'blob' }));
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
appendChildSpy.mockRestore();
|
||||
removeSpy.mockRestore();
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('strategy snapshot can be generated from overview', async () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { joinLines, splitLines } from "./profileCv";
|
||||
import { TailoredCvDraft } from "./types";
|
||||
|
||||
const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "custom"];
|
||||
|
||||
export function emptyTailoredCvDraft(): TailoredCvDraft {
|
||||
return {
|
||||
templateId: "ats-minimal",
|
||||
headline: "",
|
||||
summary: [],
|
||||
selectedSkills: [],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
renderOptions: {
|
||||
showPhoto: false,
|
||||
pageMode: "one-page",
|
||||
accentColor: "slate",
|
||||
sectionOrder: DEFAULT_SECTION_ORDER,
|
||||
bulletDensity: "balanced",
|
||||
},
|
||||
status: "empty",
|
||||
renderedText: "",
|
||||
isLegacyFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateRange(start?: string | null, end?: string | null, isCurrent?: boolean) {
|
||||
const normalizedStart = start?.trim();
|
||||
const normalizedEnd = end?.trim();
|
||||
if (!normalizedStart && !normalizedEnd) return "";
|
||||
if (!normalizedStart) return normalizedEnd ?? "";
|
||||
return `${normalizedStart} - ${isCurrent ? "Present" : normalizedEnd || "Present"}`;
|
||||
}
|
||||
|
||||
export function renderTailoredCvDraftText(source?: Partial<TailoredCvDraft> | null) {
|
||||
const draft = emptyTailoredCvDraft();
|
||||
const normalized = {
|
||||
...draft,
|
||||
...source,
|
||||
summary: Array.isArray(source?.summary) ? source.summary.filter(Boolean) : [],
|
||||
selectedSkills: Array.isArray(source?.selectedSkills) ? source.selectedSkills.filter(Boolean) : [],
|
||||
experience: Array.isArray(source?.experience) ? source.experience.filter(Boolean) : [],
|
||||
education: Array.isArray(source?.education) ? source.education.filter(Boolean) : [],
|
||||
customSections: Array.isArray(source?.customSections) ? source.customSections.filter(Boolean) : [],
|
||||
};
|
||||
|
||||
const sections: string[] = [];
|
||||
if (normalized.headline?.trim()) {
|
||||
sections.push(normalized.headline.trim());
|
||||
}
|
||||
if (normalized.summary.length) {
|
||||
sections.push(`Professional Summary\n${normalized.summary.map((item) => `- ${item.trim()}`).join("\n")}`);
|
||||
}
|
||||
if (normalized.selectedSkills.length) {
|
||||
sections.push(`Core Skills\n${normalized.selectedSkills.map((item) => item.trim()).join("\n")}`);
|
||||
}
|
||||
if (normalized.experience.length) {
|
||||
const body = normalized.experience.map((item) => {
|
||||
const header = [item.title, item.company, item.location, formatDateRange(item.start, item.end, item.isCurrent)]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const bullets = (item.bullets ?? []).filter(Boolean).map((bullet) => `- ${bullet.trim()}`).join("\n");
|
||||
return [header, bullets].filter(Boolean).join("\n");
|
||||
}).filter(Boolean).join("\n\n");
|
||||
if (body) sections.push(`Experience\n${body}`);
|
||||
}
|
||||
if (normalized.education.length) {
|
||||
const body = normalized.education.map((item) => {
|
||||
const header = [item.qualification, item.institution, item.location, formatDateRange(item.start, item.end, false)]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const details = (item.details ?? []).filter(Boolean).map((detail) => `- ${detail.trim()}`).join("\n");
|
||||
return [header, details].filter(Boolean).join("\n");
|
||||
}).filter(Boolean).join("\n\n");
|
||||
if (body) sections.push(`Education\n${body}`);
|
||||
}
|
||||
normalized.customSections.forEach((section) => {
|
||||
const title = section.title?.trim() || "Additional Information";
|
||||
const items = (section.items ?? []).filter(Boolean).map((item) => item.trim()).join("\n");
|
||||
if (items) sections.push(`${title}\n${items}`);
|
||||
});
|
||||
|
||||
return sections.join("\n\n").trim();
|
||||
}
|
||||
|
||||
export function normalizeTailoredCvDraft(source?: Partial<TailoredCvDraft> | null): TailoredCvDraft {
|
||||
const empty = emptyTailoredCvDraft();
|
||||
const normalized: TailoredCvDraft = {
|
||||
...empty,
|
||||
...source,
|
||||
templateId: source?.templateId?.trim() || empty.templateId,
|
||||
headline: source?.headline ?? "",
|
||||
summary: Array.isArray(source?.summary) ? source!.summary.filter(Boolean) : [],
|
||||
selectedSkills: Array.isArray(source?.selectedSkills) ? source!.selectedSkills.filter(Boolean) : [],
|
||||
experience: Array.isArray(source?.experience) ? source!.experience.map((item) => ({
|
||||
title: item?.title ?? "",
|
||||
company: item?.company ?? "",
|
||||
location: item?.location ?? "",
|
||||
start: item?.start ?? "",
|
||||
end: item?.end ?? "",
|
||||
isCurrent: Boolean(item?.isCurrent),
|
||||
bullets: Array.isArray(item?.bullets) ? item!.bullets.filter(Boolean) : [],
|
||||
})) : [],
|
||||
education: Array.isArray(source?.education) ? source!.education.map((item) => ({
|
||||
qualification: item?.qualification ?? "",
|
||||
institution: item?.institution ?? "",
|
||||
location: item?.location ?? "",
|
||||
start: item?.start ?? "",
|
||||
end: item?.end ?? "",
|
||||
details: Array.isArray(item?.details) ? item!.details.filter(Boolean) : [],
|
||||
})) : [],
|
||||
customSections: Array.isArray(source?.customSections) ? source!.customSections.map((item) => ({
|
||||
title: item?.title ?? "",
|
||||
items: Array.isArray(item?.items) ? item!.items.filter(Boolean) : [],
|
||||
})) : [],
|
||||
renderOptions: {
|
||||
...empty.renderOptions,
|
||||
...source?.renderOptions,
|
||||
sectionOrder: Array.isArray(source?.renderOptions?.sectionOrder) && source.renderOptions.sectionOrder.length > 0
|
||||
? source.renderOptions.sectionOrder.filter(Boolean)
|
||||
: DEFAULT_SECTION_ORDER,
|
||||
},
|
||||
status: source?.status?.trim() || empty.status,
|
||||
renderedText: "",
|
||||
isLegacyFallback: Boolean(source?.isLegacyFallback),
|
||||
};
|
||||
|
||||
normalized.renderedText = renderTailoredCvDraftText(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export { joinLines, splitLines };
|
||||
@@ -28,6 +28,57 @@ export interface WorkflowSignal {
|
||||
hasInterviewPrepNotes: boolean;
|
||||
}
|
||||
|
||||
export interface TailoredCvExperienceItem {
|
||||
title?: string | null;
|
||||
company?: string | null;
|
||||
location?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
isCurrent?: boolean;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface TailoredCvEducationItem {
|
||||
qualification?: string | null;
|
||||
institution?: string | null;
|
||||
location?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface TailoredCvCustomSection {
|
||||
title?: string | null;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface TailoredCvRenderOptions {
|
||||
showPhoto: boolean;
|
||||
pageMode: string;
|
||||
accentColor: string;
|
||||
sectionOrder: string[];
|
||||
bulletDensity: string;
|
||||
}
|
||||
|
||||
export interface TailoredCvDraft {
|
||||
id?: number | null;
|
||||
canonicalProfileVersion?: number | null;
|
||||
templateId: string;
|
||||
headline?: string | null;
|
||||
summary: string[];
|
||||
selectedSkills: string[];
|
||||
experience: TailoredCvExperienceItem[];
|
||||
education: TailoredCvEducationItem[];
|
||||
customSections: TailoredCvCustomSection[];
|
||||
renderOptions: TailoredCvRenderOptions;
|
||||
generationContextHash?: string | null;
|
||||
lastGeneratedAtUtc?: string | null;
|
||||
lastEditedAtUtc?: string | null;
|
||||
status: string;
|
||||
renderedText: string;
|
||||
isLegacyFallback: boolean;
|
||||
}
|
||||
|
||||
export interface JobApplication {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
|
||||
Reference in New Issue
Block a user