From 32d16089f1ce1b2e8a99c79c26cbb0ee3ddc046c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 30 Aug 2026 23:22:40 +0200 Subject: [PATCH] refactor(jobs): remove legacy details dialog The routed job workspace owns analysis, correspondence, timeline, and interview flows. Remove the unreachable duplicate dialog and keep coverage on the live components and route contracts. --- .../src/application-route-contracts.test.ts | 3 +- .../src/components/JobDetailsDialog.tsx | 1296 ----------------- .../src/components/JobDetailsPanels.tsx | 156 +- .../src/components/JobInsightTabs.tsx | 143 -- .../src/correspondence-gmail-import.test.tsx | 7 +- .../src/job-details-followup-drafts.test.tsx | 113 -- .../src/job-details-generated-drafts.test.tsx | 306 ---- job-tracker-ui/src/match-score-panel.test.tsx | 132 -- job-tracker-ui/src/status-suggestion.test.tsx | 90 -- 9 files changed, 7 insertions(+), 2239 deletions(-) delete mode 100644 job-tracker-ui/src/components/JobDetailsDialog.tsx delete mode 100644 job-tracker-ui/src/components/JobInsightTabs.tsx delete mode 100644 job-tracker-ui/src/job-details-followup-drafts.test.tsx delete mode 100644 job-tracker-ui/src/job-details-generated-drafts.test.tsx delete mode 100644 job-tracker-ui/src/match-score-panel.test.tsx delete mode 100644 job-tracker-ui/src/status-suggestion.test.tsx diff --git a/job-tracker-ui/src/application-route-contracts.test.ts b/job-tracker-ui/src/application-route-contracts.test.ts index f85e327..e8e5206 100644 --- a/job-tracker-ui/src/application-route-contracts.test.ts +++ b/job-tracker-ui/src/application-route-contracts.test.ts @@ -3,9 +3,8 @@ import path from "node:path"; test("application panels use distinct timeline, interview board, and generated brief routes", () => { const workspace = fs.readFileSync(path.join(process.cwd(), "src/applicationWorkspace.ts"), "utf8"); - const details = fs.readFileSync(path.join(process.cwd(), "src/components/JobDetailsDialog.tsx"), "utf8"); expect(workspace).toContain("/timeline"); expect(workspace).toContain("/interview-prep`"); - expect(details).toContain("/interview-prep/brief`"); + expect(workspace).not.toContain("/interview-prep/brief`"); }); diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx deleted file mode 100644 index b15c6a1..0000000 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ /dev/null @@ -1,1296 +0,0 @@ -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 ? ( -