Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
@@ -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>
)}