import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, Chip, CircularProgress, Dialog, DialogContent, DialogTitle, FormControl, InputLabel, MenuItem, Select, Tab, Tabs, TextField, Typography, } from "@mui/material"; import { alpha } from "@mui/material/styles"; import { api, getApiErrorMessage } from "../api"; import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, StrategySnapshotOperationResponse, TailoredCvDraft, UserOperation } from "../types"; import { statusLabel } from "../pipeline"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft"; import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; import JobInsightTabs from "./JobInsightTabs"; import { ListCard, PaperRow, WorkspaceDraftCard } from "./JobDetailsPanels"; import JobFlowBar from "./JobFlowBar"; import GradientButton from "./GradientButton"; import { useI18n } from "../i18n/I18nProvider"; import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData"; import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache"; import { useAccountPlan } from "../accountPlan"; import { upsertApplicationAnswerDraft } from "../applicationDrafts"; type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview"; type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold"; type TailoredCvPreviewResponse = { templateId: string; html: string; suggestedFileName: string; }; interface Props { open: boolean; jobId: number | null; onClose: () => void; initialTab?: number; initialFollowUpMode?: string; // Supplied by callers that live inside the router. Optional on purpose: the dialog must not depend // on router context, so it stays renderable standalone (and in tests) without a . onOpenWorkspace?: (jobId: number) => void; } function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" { switch (status) { case "Rejected": return "error"; case "Waiting": case "Ghosted": return "warning"; case "Offer": return "success"; case "Applied": default: return "primary"; } } function getFitLevel(candidateFit: CandidateFit | null): { label: string; color: "success" | "warning" | "default" } | null { if (!candidateFit) return null; if (candidateFit.fitLevel === "Strong match") return { label: candidateFit.fitLevel, color: "success" }; if (candidateFit.fitLevel === "Potential match") return { label: candidateFit.fitLevel, color: "warning" }; return { label: candidateFit.fitLevel, color: "default" }; } function getWorkspaceStatus(currentValue: string, savedValue: string) { const current = currentValue.trim(); const saved = savedValue.trim(); if (current && current !== saved) return { label: "Unsaved edits", color: "warning" as const }; if (saved) return { label: "Saved to job", color: "success" as const }; if (current) return { label: "Generated only", color: "default" as const }; 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, onOpenWorkspace }: Props) { const { canUseAi } = useAccountPlan(); const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); const followUpCache = useWorkspaceTabCache(); const candidateFitCache = useWorkspaceTabCache(); const matchScoreCache = useWorkspaceTabCache(); const focusPlanCache = useWorkspaceTabCache(); const interviewPrepCache = useWorkspaceTabCache(); const readinessCache = useWorkspaceTabCache(); const tailoredDraftCache = useWorkspaceTabCache(); const { job, setJob, tab, setTab, history, isAdmin, jobAttachments, selectedAttachmentIds, setSelectedAttachmentIds, profileAvatarImageDataUrl, packageWorkspace, setPackageWorkspace, savedPackageWorkspace, setSavedPackageWorkspace, packageGeneratedAt, setPackageGeneratedAt, draftRecipient, setDraftRecipient, followUpMode, setFollowUpMode, } = useJobWorkspaceBaseData({ open, jobId, initialTab, initialFollowUpMode, }); const [followUpDraft, setFollowUpDraft] = useState(null); const [loadingDraft, setLoadingDraft] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false); const [candidateFit, setCandidateFit] = useState(null); const [matchScore, setMatchScore] = useState(null); const [loadingMatchScore, setLoadingMatchScore] = useState(false); const [statusSuggestion, setStatusSuggestion] = useState(null); const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false); const [focusPlan, setFocusPlan] = useState(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); const [focusPlanOperation, setFocusPlanOperation] = useState(null); const announcedFocusPlanOperation = useRef(null); const focusPlanLookupVersion = useRef(0); const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false); const [interviewPrep, setInterviewPrep] = useState(null); const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false); const [readiness, setReadiness] = useState(null); const [loadingReadiness, setLoadingReadiness] = useState(false); const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false); const [generatingPackage, setGeneratingPackage] = useState(false); const [applicationPackage, setApplicationPackage] = useState(null); const [generationMode, setGenerationMode] = useState("default"); const [coverLetterStyle, setCoverLetterStyle] = useState("balanced"); const [tailoredCvDraft, setTailoredCvDraft] = useState(emptyTailoredCvDraft()); const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState(emptyTailoredCvDraft()); const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false); const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false); const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false); const [tailoredCvPreview, setTailoredCvPreview] = useState(null); const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false); const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false); const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState(null); const [useProfilePhoto, setUseProfilePhoto] = useState(true); const [draftReloadToken, setDraftReloadToken] = useState(0); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); const selectedAttachmentCsv = useMemo(() => selectedAttachmentIds.join(","), [selectedAttachmentIds]); useEffect(() => { if (!open || !jobId) return; setFollowUpDraft(null); setCandidateFit(null); setMatchScore(null); setStatusSuggestion(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); setApplicationPackage(null); setTailoredCvDraft(emptyTailoredCvDraft()); setSavedTailoredCvDraft(emptyTailoredCvDraft()); setTailoredCvPreview(null); setCustomPhotoDataUrl(null); setUseProfilePhoto(true); setDraftReloadToken(0); setDraftSubject(""); setDraftBody(""); setFocusPlanOperation(null); announcedFocusPlanOperation.current = null; focusPlanLookupVersion.current += 1; followUpCache.clearCached(); candidateFitCache.clearCached(); focusPlanCache.clearCached(); interviewPrepCache.clearCached(); readinessCache.clearCached(); tailoredDraftCache.clearCached(); }, [open, jobId, followUpCache, candidateFitCache, focusPlanCache, interviewPrepCache, readinessCache, tailoredDraftCache]); useEffect(() => { if (!open || !jobId || tab !== 3) return; const cacheKey = `${jobId}:tailored-cv-draft`; const cached = tailoredDraftCache.getCached(cacheKey); if (cached) { const normalized = normalizeTailoredCvDraft(cached); setTailoredCvDraft(normalized); setSavedTailoredCvDraft(normalized); return; } setLoadingTailoredCvDraft(true); api.get(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => { const normalized = normalizeTailoredCvDraft(r.data); tailoredDraftCache.setCached(cacheKey, normalized); setTailoredCvDraft(normalized); setSavedTailoredCvDraft(normalized); }).catch(() => { const empty = emptyTailoredCvDraft(); setTailoredCvDraft(empty); setSavedTailoredCvDraft(empty); }).finally(() => setLoadingTailoredCvDraft(false)); }, [open, jobId, tab, tailoredDraftCache]); useEffect(() => { if (!canUseAi || !open || !jobId || tab !== 4) return; const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`; const cached = followUpCache.getCached(cacheKey); if (cached) { setFollowUpDraft(cached); setDraftSubject(cached.subject); setDraftBody(cached.body); return; } setLoadingDraft(true); api.get(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { followUpCache.setCached(cacheKey, r.data); setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); }, [canUseAi, open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]); useEffect(() => { if (!canUseAi || !open || !jobId || tab !== 5 || candidateFit) return; const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`; const cached = candidateFitCache.getCached(cacheKey); if (cached) { setCandidateFit(cached); return; } setLoadingCandidateFit(true); api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { candidateFitCache.setCached(cacheKey, r.data); setCandidateFit(r.data); }).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); }, [canUseAi, open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]); // Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase // F5); Regenerate is the explicit escape hatch when the job has changed since it was written. const regenerateCandidateFit = useCallback(() => { if (!canUseAi || !jobId) return; setLoadingCandidateFit(true); api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => { candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data); setCandidateFit(r.data); toast("Candidate fit regenerated.", "success"); }).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false)); }, [canUseAi, jobId, selectedAttachmentCsv, candidateFitCache, toast]); // Match score is deterministic and cheap: load it on the Candidate Fit tab // independently of the slow AI narrative so users see the number instantly. useEffect(() => { if (!open || !jobId || tab !== 5 || matchScore) return; const cacheKey = `${jobId}:match-score`; const cached = matchScoreCache.getCached(cacheKey); if (cached) { setMatchScore(cached); return; } setLoadingMatchScore(true); api.get(`/jobapplications/${jobId}/match-score`).then((r) => { matchScoreCache.setCached(cacheKey, r.data); setMatchScore(r.data); }).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false)); }, [open, jobId, tab, matchScore, matchScoreCache]); const updateLearningRecommendation = useCallback(async (id: number, status: "done" | "dismissed") => { if (!jobId) return; try { await api.patch(`/jobapplications/${jobId}/checklist/${id}`, { status }); setMatchScore(current => { if (!current) return current; const updated = { ...current, learningRecommendations: (current.learningRecommendations ?? []).map(item => item.id === id ? { ...item, status } : item), }; matchScoreCache.setCached(`${jobId}:match-score`, updated); return updated; }); } catch (error: any) { toast(getApiErrorMessage(error, t("matchScoreLearningUpdateFailed")), "error"); } }, [jobId, matchScoreCache, t, toast]); // Suggest a status move from the latest inbound email when the workspace opens. useEffect(() => { if (!open || !jobId) return; let cancelled = false; api.get(`/jobapplications/${jobId}/status-suggestion`) .then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); }) .catch(() => { if (!cancelled) setStatusSuggestion(null); }); return () => { cancelled = true; }; }, [open, jobId]); const applyStatusSuggestion = async () => { if (!jobId || !statusSuggestion?.suggestedStatus) return; setApplyingStatusSuggestion(true); try { await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus }); setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev); setStatusSuggestion(null); toast(t("statusSuggestionApplied"), "success"); } catch (error: any) { toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error"); } finally { setApplyingStatusSuggestion(false); } }; const loadCachedFocusPlan = useCallback(async () => { if (!jobId) return null; const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`; const cached = focusPlanCache.getCached(cacheKey); if (cached) { setFocusPlan(cached); return cached; } try { const r = await api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }); focusPlanCache.setCached(cacheKey, r.data); setFocusPlan(r.data); return r.data; } catch { setFocusPlan(null); return null; } }, [jobId, selectedAttachmentCsv, focusPlanCache]); useEffect(() => { if (!canUseAi || !open || !jobId || tab !== 6 || focusPlan) return; setLoadingFocusPlan(true); void loadCachedFocusPlan().finally(() => setLoadingFocusPlan(false)); }, [canUseAi, open, jobId, tab, focusPlan, loadCachedFocusPlan]); useEffect(() => { if (!canUseAi || !open || !jobId) return; const version = ++focusPlanLookupVersion.current; api.get(`/jobapplications/${jobId}/focus-plan/operation`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }) .then(response => { if (focusPlanLookupVersion.current === version) setFocusPlanOperation(response.data); }) .catch(() => { if (focusPlanLookupVersion.current === version) setFocusPlanOperation(null); }); }, [canUseAi, open, jobId, selectedAttachmentCsv]); useEffect(() => { if (!open || !focusPlanOperation || ["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status)) return; const timer = window.setTimeout(() => { api.get(`/operations/${focusPlanOperation.id}`) .then(response => setFocusPlanOperation(response.data)) .catch(() => undefined); }, 1000); return () => window.clearTimeout(timer); }, [open, focusPlanOperation]); useEffect(() => { if (!focusPlanOperation || !["succeeded", "failed", "cancelled"].includes(focusPlanOperation.status) || announcedFocusPlanOperation.current === `${focusPlanOperation.id}:${focusPlanOperation.status}`) return; announcedFocusPlanOperation.current = `${focusPlanOperation.id}:${focusPlanOperation.status}`; if (focusPlanOperation.status === "succeeded") { void loadCachedFocusPlan().then(() => toast("Strategy snapshot completed.", "success")); } else if (focusPlanOperation.status === "failed") { toast("Strategy snapshot failed. You can retry safely.", "error"); } else { toast("Strategy snapshot cancelled.", "info"); } }, [focusPlanOperation, loadCachedFocusPlan, toast]); const regenerateFocusPlan = useCallback(async () => { if (!canUseAi || !jobId) return; setLoadingFocusPlan(true); try { const response = await api.post(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: selectedAttachmentCsv || null }); focusPlanLookupVersion.current += 1; announcedFocusPlanOperation.current = null; setFocusPlanOperation(response.data.operation); toast(response.data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info"); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to queue strategy snapshot."), "error"); } finally { setLoadingFocusPlan(false); } }, [canUseAi, jobId, selectedAttachmentCsv, toast]); const cancelFocusPlan = useCallback(async () => { if (!focusPlanOperation?.canCancel) return; try { const response = await api.post(`/operations/${focusPlanOperation.id}/cancel`); setFocusPlanOperation(response.data); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to cancel strategy snapshot."), "error"); } }, [focusPlanOperation, toast]); const retryFocusPlan = useCallback(async () => { if (!focusPlanOperation?.canRetry) return; try { announcedFocusPlanOperation.current = null; const response = await api.post(`/operations/${focusPlanOperation.id}/retry`); setFocusPlanOperation(response.data); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to retry strategy snapshot."), "error"); } }, [focusPlanOperation, toast]); useEffect(() => { if (!canUseAi || !open || !jobId || tab !== 7 || interviewPrep) return; const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`; const cached = interviewPrepCache.getCached(cacheKey); if (cached) { setInterviewPrep(cached); return; } setLoadingInterviewPrep(true); api.get(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { interviewPrepCache.setCached(cacheKey, r.data); setInterviewPrep(r.data); }).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); }, [canUseAi, open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]); // Interview prep is now persisted server-side (career-workspace-implementation-roadmap.md // Phase F5) so it survives tab switches without re-running the AI call. Regenerate is the // explicit escape hatch for when the underlying job/notes have changed since it was written. const regenerateInterviewPrep = useCallback(() => { if (!canUseAi || !jobId) return; setLoadingInterviewPrep(true); api.get(`/jobapplications/${jobId}/interview-prep/brief`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => { interviewPrepCache.setCached(`${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`, r.data); setInterviewPrep(r.data); toast("Interview prep regenerated.", "success"); }).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate interview prep."), "error")).finally(() => setLoadingInterviewPrep(false)); }, [canUseAi, jobId, selectedAttachmentCsv, interviewPrepCache, toast]); useEffect(() => { setFollowUpDraft(null); setCandidateFit(null); setFocusPlan(null); setInterviewPrep(null); }, [selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 8 || readiness) return; const cacheKey = `${jobId}:readiness`; const cached = readinessCache.getCached(cacheKey); if (cached) { setReadiness(cached); return; } setLoadingReadiness(true); api.get(`/jobapplications/${jobId}/readiness`).then((r) => { readinessCache.setCached(cacheKey, r.data); setReadiness(r.data); }).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false)); }, [open, jobId, tab, readiness, readinessCache]); const tags: string[] = (() => { const raw = job?.tags; if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []; } catch { return []; } })(); const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : t("addJobApplication"); const checklist = [ job?.hasResume ? t("jobDetailsResume") : null, job?.hasCoverLetter ? t("jobDetailsCoverLetter") : null, job?.hasPortfolio ? t("jobDetailsPortfolio") : null, job?.hasOtherAttachment ? t("jobDetailsOther") : null, ].filter(Boolean).join(", ") || t("jobDetailsNotAvailable"); const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? t("jobTableNoSummaryYet"); const translatedDescriptionText = job?.translatedDescription?.trim() || ""; const originalDescriptionText = job?.description?.trim() || ""; const showTranslatedText = translatedDescriptionText.length > 0; const showOriginalText = originalDescriptionText.length > 0; const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); const showAiAttachmentPicker = tab >= 3 && tab <= 7 && jobAttachments.length > 0; const attachmentPicker = showAiAttachmentPicker ? ( {t("jobDetailsAttachmentContextPicker")} {jobAttachments.map((attachment) => { const selected = selectedAttachmentIds.includes(attachment.id); return ( setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))} /> ); })} ) : null; 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 = [ 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, }); tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized); setTailoredCvDraft(normalized); setSavedTailoredCvDraft(normalized); setJob((prev) => prev ? { ...prev, tailoredCvText: normalized.renderedText, tailoredCvUpdatedAt: new Date().toISOString(), } : prev); readinessCache.clearCached(); 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(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } }); const normalized = normalizeTailoredCvDraft(res.data); tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized); 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(`/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 draftsChanged = packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() || packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() || packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(); if (!draftsChanged) { toast("No unsaved package changes.", "info"); return; } try { setSavingApplicationDrafts(true); await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: packageWorkspace.coverLetter, notes: nextNotes, recruiterMessageDraft: packageWorkspace.recruiterMessage, }); setJob((prev) => prev ? { ...prev, coverLetterText: packageWorkspace.coverLetter, recruiterMessageDraft: packageWorkspace.recruiterMessage, notes: nextNotes, } : prev); setSavedPackageWorkspace({ ...packageWorkspace }); readinessCache.clearCached(); interviewPrepCache.clearCached(); setReadiness(null); setInterviewPrep(null); toast("Application package saved to this job.", "success"); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to save the application package."), "error"); } finally { setSavingApplicationDrafts(false); } }; const resetPackageWorkspaceToSaved = () => { setPackageWorkspace(savedPackageWorkspace); toast("Restored the last saved package.", "info"); }; return ( {t("jobTableOpen")} {title} {job && } {summaryFirstText} {/* Phase 5: the full-page Application Workspace. The dialog stays as the quick view. */} {jobId && onOpenWorkspace ? ( ) : null} setTab(v)} sx={{ mb: 2, "& .MuiTab-root": { fontWeight: 600, textTransform: "none", minHeight: 44 } }} variant="scrollable" allowScrollButtonsMobile > {isAdmin ? : null} {attachmentPicker} {statusSuggestion?.hasSuggestion ? ( alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> {t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })} {t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })} ) : null} {tab === 0 && ( {t("jobDetailsStrategySnapshot")} { if (!jobId) return; setLoadingStrategySnapshot(true); try { const [fitRes, operationRes] = await Promise.all([ api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }), api.post(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: selectedAttachmentCsv || null }), ]); candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data); setCandidateFit(fitRes.data); focusPlanLookupVersion.current += 1; announcedFocusPlanOperation.current = null; setFocusPlanOperation(operationRes.data.operation); } catch { toast(t("jobDetailsStrategySnapshotFailed"), "error"); } finally { setLoadingStrategySnapshot(false); } }}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : canUseAi ? t("jobDetailsGenerateStrategySnapshot") : "Pro required"} {focusPlanOperation && focusPlanOperation.status !== "succeeded" ? ( {focusPlanOperation.canCancel ? : null} {focusPlanOperation.canRetry ? : null} }> Strategy snapshot: {strategyOperationLabel(focusPlanOperation)} ) : null} {candidateFit || focusPlan ? ( {candidateFit ? = 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} /> : null} {candidateFit?.fitLevel ? : null} {focusPlan?.strategicSummary ? {focusPlan.strategicSummary} : null} {candidateFit?.matchSummary ? {candidateFit.matchSummary} : null} {focusPlan?.immediatePriorities?.length ? : null} ) : ( {t("jobDetailsStrategySnapshotEmpty")} )} {t("jobDetailsDateApplied")}{job?.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—"} {t("jobDetailsDaysSince")}{job?.daysSince ?? "—"} {t("jobTableLocation")}{job?.location ?? ""} {t("jobDetailsSalary")}{job?.salary ?? ""} {t("jobDetailsNextAction")}{job?.nextAction ?? ""} {t("jobDetailsFollowUp")}{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""} {t("jobDetailsDeadline")}{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""} {t("jobDetailsTags")}{tags.length === 0 ? - : tags.map((t) => )} {t("jobDetailsAttachmentTypes")}{checklist} {t("jobDetailsJobUrl")}{job?.jobUrl ? {job.jobUrl} : ""} {t("jobDetailsSummaryAndSkills")} {summaryFirstText} {showTranslatedText ? ( {t("jobDetailsTranslatedRoleText")} {translatedDescriptionText} ) : null} {showOriginalText ? ( {t("jobDetailsOriginalRoleText")} {originalDescriptionText} ) : null} {t("editJobNotes")}{job?.notes ?? ""} )} {tab === 1 && jobId && } {tab === 2 && jobId && } {tab === 3 && ( Tailored CV draft This draft is job-scoped. It stays separate from your master CV and from the package drafts below. {t("jobDetailsTailoredCvMode")} Template setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, renderOptions: { ...current.renderOptions, accentColor: e.target.value }, status: "edited", }))} sx={{ width: 110 }} InputLabelProps={{ shrink: true }} /> {customPhotoDataUrl ? : null} {tailoredCvDraft.isLegacyFallback ? : null} {tailoredCvDraft.lastGeneratedAtUtc ? : null} {tailoredCvDraft.canonicalProfileVersion ? : null} {loadingTailoredCvDraft ? ( ) : ( setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))} fullWidth /> setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))} multiline minRows={5} fullWidth helperText="One bullet per line." /> setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))} multiline minRows={4} fullWidth helperText="One skill per line." /> [ [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." /> [ [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." /> `${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." /> Rendered CV snapshot This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft. {t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })} PDF-style preview Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here. {tailoredCvPreview ? (