import React, { 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 { api, getApiErrorMessage } from "../api"; import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; import JobFlowBar from "./JobFlowBar"; import { useI18n } from "../i18n/I18nProvider"; type AttachmentItem = { id: number; fileName: string; uploadDate: string; fileType: string; fileSize: number; purpose?: string | null; useForAi: boolean; }; type FollowUpDraft = { subject: string; body: string; reason: string; suggestedSendOn: string; }; type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview"; type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold"; type PackageDraftKind = "tailoredCv" | "coverLetter" | "applicationAnswer" | "recruiterMessage"; type PackageWorkspaceState = { coverLetter: string; applicationAnswer: string; recruiterMessage: 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 = "<<>>"; const APPLICATION_ANSWER_END = "<<>>"; function extractApplicationAnswerDraft(notes?: string | null) { const value = (notes ?? "").trim(); if (!value) return ""; const startIndex = value.indexOf(APPLICATION_ANSWER_START); const endIndex = value.indexOf(APPLICATION_ANSWER_END); if (startIndex >= 0 && endIndex > startIndex) { return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim(); } const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i); return legacyMatch?.[1]?.trim() ?? ""; } 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 }; } export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) { const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); const [job, setJob] = useState(null); const [tab, setTab] = useState(0); const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]); const [isAdmin, setIsAdmin] = useState(false); const [followUpDraft, setFollowUpDraft] = useState(null); const [loadingDraft, setLoadingDraft] = useState(false); const [sendingDraft, setSendingDraft] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false); const [candidateFit, setCandidateFit] = useState(null); const [focusPlan, setFocusPlan] = useState(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); 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 [jobAttachments, setJobAttachments] = useState([]); const [selectedAttachmentIds, setSelectedAttachmentIds] = useState([]); const [savingTailoredCv, setSavingTailoredCv] = 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 [tailoredCvText, setTailoredCvText] = useState(""); const [packageWorkspace, setPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); const [savedPackageWorkspace, setSavedPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); const [packageGeneratedAt, setPackageGeneratedAt] = useState(null); const [draftRecipient, setDraftRecipient] = useState(""); const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply"); const [draftReloadToken, setDraftReloadToken] = useState(0); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); const selectedAttachmentCsv = useMemo(() => selectedAttachmentIds.join(","), [selectedAttachmentIds]); useEffect(() => { if (!open || !jobId) return; setTab(Math.max(0, Math.min(9, initialTab))); setFollowUpDraft(null); setCandidateFit(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); setApplicationPackage(null); setJobAttachments([]); setSelectedAttachmentIds([]); setPackageGeneratedAt(null); setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); api.get(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setTailoredCvText(r.data.tailoredCvText ?? ""); const savedWorkspace = { coverLetter: r.data.coverLetterText ?? "", applicationAnswer: extractApplicationAnswerDraft(r.data.notes), recruiterMessage: r.data.recruiterMessageDraft ?? "", }; setSavedPackageWorkspace(savedWorkspace); setPackageWorkspace(savedWorkspace); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply")); }); api.get(`/attachments/${jobId}`).then((r) => { const items = Array.isArray(r.data) ? r.data : []; setJobAttachments(items); const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id); setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id)); }).catch(() => { setJobAttachments([]); setSelectedAttachmentIds([]); }); api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false)); api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); }, [open, jobId, initialTab, initialFollowUpMode]); useEffect(() => { if (!open || !jobId || tab !== 4) return; setLoadingDraft(true); api.get(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); }, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 5 || candidateFit) return; setLoadingCandidateFit(true); api.get(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); }, [open, jobId, tab, candidateFit, selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; setLoadingFocusPlan(true); api.get(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); }, [open, jobId, tab, focusPlan, selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 7 || interviewPrep) return; setLoadingInterviewPrep(true); api.get(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); }, [open, jobId, tab, interviewPrep, selectedAttachmentCsv]); useEffect(() => { setFollowUpDraft(null); setCandidateFit(null); setFocusPlan(null); setInterviewPrep(null); }, [selectedAttachmentCsv]); useEffect(() => { if (!open || !jobId || tab !== 8 || readiness) return; setLoadingReadiness(true); api.get(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false)); }, [open, jobId, tab, readiness]); 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 tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? ""); const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter); const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer); const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage); const hasUnsavedPackageChanges = [ tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(), packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(), packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(), packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(), ].some(Boolean); const savePackageWorkspace = async () => { if (!jobId || !job) return; const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer); const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim(); const draftsChanged = packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() || packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() || packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(); if (!tailoredCvChanged && !draftsChanged) { toast("No unsaved package changes.", "info"); return; } try { if (tailoredCvChanged) { setSavingTailoredCv(true); await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText }); } if (draftsChanged) { setSavingApplicationDrafts(true); await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: packageWorkspace.coverLetter, notes: nextNotes, recruiterMessageDraft: packageWorkspace.recruiterMessage, }); } setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt, coverLetterText: packageWorkspace.coverLetter, recruiterMessageDraft: packageWorkspace.recruiterMessage, notes: nextNotes, } : prev); setSavedPackageWorkspace({ ...packageWorkspace }); 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 { setSavingTailoredCv(false); setSavingApplicationDrafts(false); } }; const resetPackageWorkspaceToSaved = () => { setTailoredCvText(job?.tailoredCvText ?? ""); setPackageWorkspace(savedPackageWorkspace); toast("Restored the last saved package.", "info"); }; return ( {t("jobTableOpen")} {title} {job && } {summaryFirstText} setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile> {isAdmin ? : null} {attachmentPicker} {tab === 0 && ( {t("jobDetailsStrategySnapshot")} {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 ? 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 && ( {t("jobDetailsTabTailoredCv")} Build the package here, then save the working copy back onto this job. {t("jobDetailsTailoredCvMode")} {t("jobDetailsCoverLetterStyle")} {packageGeneratedAt ? : null} {t("jobDetailsTailoredCvIntro")} setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} /> {t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })} setPackageWorkspace((current) => ({ ...current, coverLetter: value }))} statusLabel={coverLetterStatus.label} statusColor={coverLetterStatus.color} /> setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))} statusLabel={applicationAnswerStatus.label} statusColor={applicationAnswerStatus.color} /> setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))} statusLabel={recruiterMessageStatus.label} statusColor={recruiterMessageStatus.color} /> Saved working material These saved copies are what later slices can trust and reuse. Tailored CV: {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"} Cover letter: {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"} Application answer: {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"} Recruiter message: {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"} )} {tab === 4 && ( {loadingDraft ? : followUpDraft ? ( {t("jobDetailsReason")}{followUpDraft.reason} {t("jobDetailsSuggestedSendDate")}{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()} {t("jobDetailsFollowUpMode")} setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} /> setDraftSubject(e.target.value)} /> setDraftBody(e.target.value)} /> ) : {t("jobDetailsNoDraftAvailable")}} )} {tab === 5 && ( {loadingCandidateFit ? : candidateFit ? ( {t("jobDetailsHowYouMatch")}{candidateFit.matchSummary} = 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" /> {fitLevel ? : null} ) : {t("jobDetailsCandidateFitEmpty")}} )} {tab === 6 && ( {loadingFocusPlan ? : focusPlan ? ( ) : {t("jobDetailsNoFocusPlan")}} )} {tab === 7 && ( {loadingInterviewPrep ? : interviewPrep ? ( ) : {t("jobDetailsNoInterviewPrep")}} )} {tab === 8 && ( {loadingReadiness ? : readiness ? ( {t("jobDetailsApplicationReadiness")} = 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} /> ) : {t("jobDetailsNoReadiness")}} )} {tab === 9 && isAdmin && ( {history.length === 0 ? {t("jobDetailsNoHistory")} : history.map((entry) => )} )} ); } function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) { const { t } = useI18n(); return ( {title} {items.length ? items.map((item) => ) : {t("jobDetailsNothingHighlighted")}} ); } function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) { return ( ); } function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) { const { t } = useI18n(); return ( {title} {subtitle ? {subtitle} : null} {items.length ? items.map((item, index) => • {item}) : {t("jobDetailsNothingHighlighted")}} ); } 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 ( {title} onChange(e.target.value)} multiline minRows={7} fullWidth /> ); } function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise | void; saving?: boolean }) { const { t } = useI18n(); const [value, setValue] = React.useState(content); React.useEffect(() => { setValue(content); }, [content]); return ( {title} {onSave ? : null} setValue(e.target.value)} multiline minRows={6} fullWidth /> ); } function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) { return ( {type} {oldValue || newValue ? {" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""}) : null} {at ? new Date(at).toLocaleString() : ""} {note ? ` - ${note}` : ""} ); }