00a035ea20
Extends the interview-prep persistence pattern (previous commit) to the other two AI-generated per-job outputs that were re-running their full AI call chain on every tab open: candidate-fit (4 AI calls) and focus-plan (4 AI calls). Across all three tabs that's 9 AI calls fired every single time a user revisits a job's AI workspace tabs. Generalized into AiWorkspaceNote (OwnerUserId, JobApplicationId, NoteType, ResultJson) rather than duplicating InterviewPrepNote's per-field-column shape: CandidateFitDto and FocusPlanDto are irregular and nested (up to 13 fields including a nested guidance object), where per-field columns would be unreasonable. One table, keyed by note type, serving both. Same rules as interview prep: reuse across calls, regenerate when the attachment selection changes, regenerate on explicit refresh. Frontend gets the same "Regenerate" button on both tabs. 3 new tests (persist+reuse for both, refresh for candidate-fit). Verified against the real dev DB.
1435 lines
84 KiB
TypeScript
1435 lines
84 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
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, TailoredCvDraft } 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 JobFlowBar from "./JobFlowBar";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
|
|
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
|
|
|
|
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;
|
|
}
|
|
|
|
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 copyLines(items: string[]) {
|
|
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
|
}
|
|
|
|
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
|
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
|
|
|
function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
|
|
const trimmedNotes = (notes ?? "").trim();
|
|
const trimmedDraft = draft.trim();
|
|
const block = trimmedDraft
|
|
? `${APPLICATION_ANSWER_START}\n${trimmedDraft}\n${APPLICATION_ANSWER_END}`
|
|
: "";
|
|
|
|
if (!trimmedNotes) return block;
|
|
|
|
const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g");
|
|
if (markerPattern.test(trimmedNotes)) {
|
|
return block ? trimmedNotes.replace(markerPattern, block).trim() : trimmedNotes.replace(markerPattern, "").trim();
|
|
}
|
|
|
|
const legacyPattern = /(?:\n\n)?Application answer draft:\s*\n[\s\S]*$/i;
|
|
if (legacyPattern.test(trimmedNotes)) {
|
|
return block ? trimmedNotes.replace(legacyPattern, `\n\n${block}`).trim() : trimmedNotes.replace(legacyPattern, "").trim();
|
|
}
|
|
|
|
return block ? `${trimmedNotes}\n\n${block}` : trimmedNotes;
|
|
}
|
|
|
|
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 }: Props) {
|
|
const { toast } = useToast();
|
|
const { t } = useI18n();
|
|
const { confirmAction } = useDialogActions();
|
|
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
|
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
|
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
|
|
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
|
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
|
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
|
const tailoredDraftCache = useWorkspaceTabCache<TailoredCvDraft>();
|
|
|
|
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<FollowUpDraft | null>(null);
|
|
const [loadingDraft, setLoadingDraft] = useState(false);
|
|
const [sendingDraft, setSendingDraft] = useState(false);
|
|
const [refreshingAi, setRefreshingAi] = useState(false);
|
|
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
|
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
|
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
|
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
|
|
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
|
|
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
|
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
|
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
|
const [loadingStrategySnapshot, setLoadingStrategySnapshot] = useState(false);
|
|
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
|
|
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
|
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
|
const [loadingReadiness, setLoadingReadiness] = 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 [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 [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(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("");
|
|
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<TailoredCvDraft>(`/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 (!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<FollowUpDraft>(`/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));
|
|
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
|
|
|
|
useEffect(() => {
|
|
if (!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<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
|
candidateFitCache.setCached(cacheKey, r.data);
|
|
setCandidateFit(r.data);
|
|
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
|
}, [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 (!jobId) return;
|
|
setLoadingCandidateFit(true);
|
|
api.get<CandidateFit>(`/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));
|
|
}, [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<MatchScore>(`/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]);
|
|
|
|
// Suggest a status move from the latest inbound email when the workspace opens.
|
|
useEffect(() => {
|
|
if (!open || !jobId) return;
|
|
let cancelled = false;
|
|
api.get<StatusSuggestion>(`/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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
|
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
|
const cached = focusPlanCache.getCached(cacheKey);
|
|
if (cached) {
|
|
setFocusPlan(cached);
|
|
return;
|
|
}
|
|
|
|
setLoadingFocusPlan(true);
|
|
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
|
focusPlanCache.setCached(cacheKey, r.data);
|
|
setFocusPlan(r.data);
|
|
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
|
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
|
|
|
const regenerateFocusPlan = useCallback(() => {
|
|
if (!jobId) return;
|
|
setLoadingFocusPlan(true);
|
|
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
|
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
|
|
setFocusPlan(r.data);
|
|
toast("Focus plan regenerated.", "success");
|
|
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
|
|
}, [jobId, selectedAttachmentCsv, focusPlanCache, toast]);
|
|
|
|
useEffect(() => {
|
|
if (!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<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
|
interviewPrepCache.setCached(cacheKey, r.data);
|
|
setInterviewPrep(r.data);
|
|
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
|
}, [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 (!jobId) return;
|
|
setLoadingInterviewPrep(true);
|
|
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { 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));
|
|
}, [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<ReadinessResponse>(`/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 ? (
|
|
<Box sx={{ mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("jobDetailsAttachmentContextPicker")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Button size="small" variant="text" onClick={() => setSelectedAttachmentIds(jobAttachments.slice(0, 4).map((item) => item.id))}>{t("jobDetailsAttachmentSelectTop")}</Button>
|
|
<Button size="small" variant="text" onClick={() => setSelectedAttachmentIds([])}>{t("jobDetailsAttachmentClear")}</Button>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{jobAttachments.map((attachment) => {
|
|
const selected = selectedAttachmentIds.includes(attachment.id);
|
|
return (
|
|
<Chip
|
|
key={attachment.id}
|
|
label={attachment.fileName}
|
|
color={selected ? "primary" : "default"}
|
|
variant={selected ? "filled" : "outlined"}
|
|
onClick={() => setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))}
|
|
/>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
) : 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<TailoredCvDraft>(`/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<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 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 (
|
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
|
<DialogTitle sx={{ pb: 1 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableOpen")}</Typography>
|
|
<Typography variant="h6">{title}</Typography>
|
|
</Box>
|
|
{job && <Chip label={job.status} color={statusChipColor(job.status)} size="small" />}
|
|
</Box>
|
|
</DialogTitle>
|
|
|
|
<DialogContent>
|
|
<JobFlowBar job={job} history={history} />
|
|
<Box sx={{ mt: 1.5, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
|
|
</Box>
|
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
|
|
<Tab label={t("jobTableOverview")} />
|
|
<Tab label={t("jobDetailsTabCorrespondence")} />
|
|
<Tab label={t("jobDetailsTabAttachments")} />
|
|
<Tab label={t("jobDetailsTabTailoredCv")} />
|
|
<Tab label={t("jobTableFollowUp")} />
|
|
<Tab label={t("jobDetailsTabCandidateFit")} />
|
|
<Tab label={t("jobDetailsTabFocusPlan")} />
|
|
<Tab label={t("jobDetailsTabInterviewPrep")} />
|
|
<Tab label={t("jobTableReadiness")} />
|
|
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
|
|
</Tabs>
|
|
|
|
{attachmentPicker}
|
|
|
|
{statusSuggestion?.hasSuggestion ? (
|
|
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
|
<Box>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1 }}>
|
|
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
|
|
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
|
</Button>
|
|
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
|
|
{tab === 0 && (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="overline">{t("jobDetailsStrategySnapshot")}</Typography>
|
|
<Button size="small" variant="outlined" disabled={loadingStrategySnapshot} onClick={async () => {
|
|
if (!jobId) return;
|
|
setLoadingStrategySnapshot(true);
|
|
try {
|
|
const [fitRes, focusRes] = await Promise.all([
|
|
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
|
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
|
]);
|
|
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data);
|
|
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, focusRes.data);
|
|
setCandidateFit(fitRes.data);
|
|
setFocusPlan(focusRes.data);
|
|
} catch {
|
|
toast(t("jobDetailsStrategySnapshotFailed"), "error");
|
|
} finally {
|
|
setLoadingStrategySnapshot(false);
|
|
}
|
|
}}>{loadingStrategySnapshot ? t("jobDetailsRefreshing") : t("jobDetailsGenerateStrategySnapshot")}</Button>
|
|
</Box>
|
|
{candidateFit || focusPlan ? (
|
|
<Box sx={{ gridColumn: "1 / -1", p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
|
{candidateFit ? <Chip size="small" color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} /> : null}
|
|
{candidateFit?.fitLevel ? <Chip size="small" variant="outlined" label={candidateFit.fitLevel} /> : null}
|
|
</Box>
|
|
{focusPlan?.strategicSummary ? <Typography sx={{ whiteSpace: "pre-wrap", mb: 1 }}>{focusPlan.strategicSummary}</Typography> : null}
|
|
{candidateFit?.matchSummary ? <Typography sx={{ color: "text.secondary", whiteSpace: "pre-wrap", mb: 1.5 }}>{candidateFit.matchSummary}</Typography> : null}
|
|
{focusPlan?.immediatePriorities?.length ? <ListCard title={t("jobDetailsImmediatePriorities")} items={focusPlan.immediatePriorities.slice(0, 3)} /> : null}
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<Typography sx={{ color: "text.secondary" }}>{t("jobDetailsStrategySnapshotEmpty")}</Typography>
|
|
</Box>
|
|
)}
|
|
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobDetailsSalary")}</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobDetailsNextAction")}</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobDetailsFollowUp")}</Typography><Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobDetailsDeadline")}</Typography><Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography></Box>
|
|
<Box><Typography variant="overline">{t("jobDetailsTags")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>-</Typography> : tags.map((t) => <Chip key={t} label={t} size="small" />)}</Box></Box>
|
|
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobDetailsAttachmentTypes")}</Typography><Typography>{checklist}</Typography></Box>
|
|
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobDetailsJobUrl")}</Typography><Typography>{job?.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{job.jobUrl}</a> : ""}</Typography></Box>
|
|
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
|
|
<Typography variant="overline">{t("jobDetailsSummaryAndSkills")}</Typography>
|
|
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
|
|
if (!jobId) return;
|
|
if (!(await confirmAction(t("jobDetailsRefreshAiConfirm"), { title: t("jobDetailsRefreshAiTitle"), confirmLabel: t("jobDetailsRefreshAi") }))) return;
|
|
setRefreshingAi(true);
|
|
try {
|
|
const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
|
|
setJob(res.data);
|
|
toast(t("jobDetailsSummaryRefreshed"), "success");
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, t("jobDetailsSummaryRefreshFailed")), "error");
|
|
} finally {
|
|
setRefreshingAi(false);
|
|
}
|
|
}}>{refreshingAi ? t("jobDetailsRefreshing") : t("jobDetailsRefreshAi")}</Button>
|
|
</Box>
|
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
|
|
</Box>
|
|
{showTranslatedText ? (
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<Typography variant="overline">{t("jobDetailsTranslatedRoleText")}</Typography>
|
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{translatedDescriptionText}</Typography>
|
|
</Box>
|
|
) : null}
|
|
{showOriginalText ? (
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<Typography variant="overline">{t("jobDetailsOriginalRoleText")}</Typography>
|
|
<Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{originalDescriptionText}</Typography>
|
|
</Box>
|
|
) : null}
|
|
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("editJobNotes")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box>
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 1 && jobId && <Correspondence jobId={jobId} job={job} />}
|
|
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
|
|
|
{tab === 3 && (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<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">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 }}>
|
|
<InputLabel>{t("jobDetailsTailoredCvMode")}</InputLabel>
|
|
<Select value={generationMode} label={t("jobDetailsTailoredCvMode")} onChange={(e) => setGenerationMode(e.target.value as GenerationMode)}>
|
|
<MenuItem value="default">{t("jobDetailsGenerationDefault")}</MenuItem>
|
|
<MenuItem value="concise">{t("jobDetailsGenerationConcise")}</MenuItem>
|
|
<MenuItem value="ats">{t("jobDetailsGenerationAts")}</MenuItem>
|
|
<MenuItem value="achievement">{t("jobDetailsGenerationAchievement")}</MenuItem>
|
|
<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)}>
|
|
<MenuItem value="balanced">{t("jobDetailsCoverLetterStyleBalanced")}</MenuItem>
|
|
<MenuItem value="concise">{t("jobDetailsCoverLetterStyleConcise")}</MenuItem>
|
|
<MenuItem value="formal">{t("jobDetailsCoverLetterStyleFormal")}</MenuItem>
|
|
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<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);
|
|
setPackageWorkspace({
|
|
coverLetter: res.data.coverLetterDraft ?? "",
|
|
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
|
recruiterMessage: res.data.recruiterMessageDraft ?? "",
|
|
});
|
|
setPackageGeneratedAt(new Date().toISOString());
|
|
toast(t("jobDetailsPackageGenerated"), "success");
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, t("jobDetailsPackageGenerationFailed")), "error");
|
|
} finally {
|
|
setGeneratingPackage(false);
|
|
}
|
|
}}>{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={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={`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>
|
|
|
|
<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>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 4 && (
|
|
<Box>
|
|
{loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Typography variant="overline">Follow-up context</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{followUpDraft.threadSubject ? <Chip size="small" variant="outlined" label={`Thread: ${followUpDraft.threadSubject}`} /> : null}
|
|
{followUpDraft.lastCorrespondenceAt ? <Chip size="small" variant="outlined" label={`Last activity: ${new Date(followUpDraft.lastCorrespondenceAt).toLocaleDateString()}`} /> : null}
|
|
</Box>
|
|
</Box>
|
|
<Typography sx={{ whiteSpace: "pre-wrap", mb: 1.5 }}>{followUpDraft.contextSummary}</Typography>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
|
<Box><Typography variant="overline">Why now</Typography><Typography>{followUpDraft.reason}</Typography></Box>
|
|
<Box><Typography variant="overline">Last sender</Typography><Typography>{followUpDraft.lastCorrespondenceFrom ?? "No imported sender yet"}</Typography></Box>
|
|
</Box>
|
|
{followUpDraft.contextSignals?.length ? <Box sx={{ mt: 1.5 }}><ListCard title="Draft grounding" items={followUpDraft.contextSignals} /></Box> : null}
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<FormControl size="small" sx={{ minWidth: 240 }}>
|
|
<InputLabel>{t("jobDetailsFollowUpMode")}</InputLabel>
|
|
<Select value={followUpMode} label={t("jobDetailsFollowUpMode")} onChange={(e) => setFollowUpMode(e.target.value)}>
|
|
<MenuItem value="post-apply">{t("jobDetailsFollowUpModePostApply")}</MenuItem>
|
|
<MenuItem value="waiting-update">{t("jobDetailsFollowUpModeWaiting")}</MenuItem>
|
|
<MenuItem value="post-interview">{t("jobDetailsFollowUpModePostInterview")}</MenuItem>
|
|
<MenuItem value="offer-checkin">{t("jobDetailsFollowUpModeOffer")}</MenuItem>
|
|
<MenuItem value="feedback-request">{t("jobDetailsFollowUpModeFeedback")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Button variant="outlined" onClick={() => setDraftReloadToken((value) => value + 1)}>{t("jobDetailsRegenerateDraft")}</Button>
|
|
</Box>
|
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: "background.default" }}>
|
|
<Typography variant="overline">Manual send boundary</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
Generating or regenerating this grounded draft never sends recruiter email. The only outbound step is the explicit “Send and log email” action below.
|
|
</Typography>
|
|
</Box>
|
|
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} />
|
|
<TextField label={t("jobDetailsSubject")} value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
|
|
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." />
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
|
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>{t("jobDetailsCopyDraft")}</Button>
|
|
<Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => {
|
|
if (!jobId) return;
|
|
setSendingDraft(true);
|
|
try {
|
|
await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null });
|
|
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
|
|
readinessCache.clearCached();
|
|
setReadiness(null);
|
|
toast(t("jobDetailsFollowUpSent"), "success");
|
|
} catch (error: any) {
|
|
toast(getApiErrorMessage(error, t("jobDetailsFollowUpSendFailed")), "error");
|
|
} finally {
|
|
setSendingDraft(false);
|
|
}
|
|
}}>{sendingDraft ? t("jobDetailsSending") : t("jobDetailsSendAndLogEmail")}</Button>
|
|
</Box>
|
|
</Box>
|
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoDraftAvailable")}</Typography>}
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 5 && (
|
|
<Box>
|
|
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
|
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
|
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
|
</Button>
|
|
</Box>
|
|
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
|
|
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
|
|
</Box>
|
|
</Box>
|
|
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
|
|
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
|
|
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
|
|
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
|
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
|
|
</Box>
|
|
</Box>
|
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 6 && (
|
|
<Box>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
|
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
|
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
|
</Button>
|
|
</Box>
|
|
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
|
|
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
|
|
</Box>
|
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 7 && (
|
|
<Box>
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
|
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
|
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
|
</Button>
|
|
</Box>
|
|
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
|
|
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
|
|
</Box>
|
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 8 && (
|
|
<Box>
|
|
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
|
<Chip label={readiness.level} variant="outlined" />
|
|
</Box>
|
|
</Box>
|
|
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
|
|
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
|
|
</Box>
|
|
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
|
|
</Box>
|
|
)}
|
|
|
|
{tab === 9 && isAdmin && (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoHistory")}</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)}
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
|
const { t } = useI18n();
|
|
|
|
if (loading && !score) {
|
|
return (
|
|
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
|
|
<CircularProgress size={18} />
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (!score) return null;
|
|
|
|
const color: "success" | "warning" | "error" | "inherit" =
|
|
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
|
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
|
|
|
return (
|
|
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
|
{score.hasEnoughSignal ? (
|
|
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
|
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
|
<CircularProgress
|
|
variant="determinate"
|
|
value={score.score}
|
|
size={92}
|
|
thickness={4}
|
|
aria-hidden="true"
|
|
color={color === "inherit" ? "primary" : color}
|
|
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
|
/>
|
|
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
|
)}
|
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
|
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
|
</Box>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
<Box>
|
|
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
{score.sectionCoverage.length ? (
|
|
<Box sx={{ mt: 1.5 }}>
|
|
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="overline">{title}</Typography>
|
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
|
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
|
return (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
<ListCard title={leftTitle} items={leftItems} />
|
|
<ListCard title={rightTitle} items={rightItems} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Box>
|
|
<Typography variant="overline">{title}</Typography>
|
|
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
|
</Box>
|
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
|
</Box>
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
|
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Typography variant="overline">{title}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Chip size="small" color={statusColor} label={statusLabel} />
|
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
|
</Box>
|
|
</Box>
|
|
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
|
const { t } = useI18n();
|
|
const [value, setValue] = React.useState(content);
|
|
|
|
React.useEffect(() => {
|
|
setValue(content);
|
|
}, [content]);
|
|
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Typography variant="overline">{title}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
|
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
|
|
</Box>
|
|
</Box>
|
|
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
|
return (
|
|
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
|
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
|
{type}
|
|
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{at ? new Date(at).toLocaleString() : ""}
|
|
{note ? ` - ${note}` : ""}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|