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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user