fc56f94d56
Restyle JobDetailsDialog.tsx (04-job-workspace.png mockup) within its existing dialog/tab structure -- the real app splits Correspondence, Attachments, and Candidate Fit into separate tabs rather than the mockup's single-screen 2x2 card grid, so this is a visual-language pass over the existing IA, not a restructure: - Header: bolder title (h5/800), heavier status chip, cleaner no-underline tab styling. - Every flat bordered "fake card" Box (11 instances across all tabs, plus the 2 in the Overview strategy-snapshot panel) becomes a floating shadow card with no border, matching every other screen redesigned this session. - The two genuinely AI-generation actions (Generate Strategy Snapshot, and by extension the shared GradientButton component) get the mockup's signature gradient CTA treatment; the confirm-gated "Refresh AI summary" action stays a plain outlined button so the gradient doesn't get diluted by a second use on the same tab. Also fixes a real bug surfaced by actually using GradientButton for the first time: its sx callback read theme.vars.customShadows, which throws when a component renders without this app's ThemeProvider -- true in production always, but true in every test in this repo (none of them wrap with a ThemeProvider), so every test touching a GradientButton or one of these restyled boxes crashed. Fixed by using a static shadow value instead of a theme.vars lookup in both the component and this file, matching the fact that inline sx callbacks execute against whatever theme is in context (unlike theme.components styleOverrides, which only run when this app's real theme is actually provided). Verified: tsc clean, full suite green (65/65, including 4 test files that render this exact dialog). Live check: booted the backend and loaded the dashboard through a fresh Next.js dev server + cache (cleared .next after chasing what turned out to be a stale console-log history in the Browser pane tooling, not a real compile error) -- confirmed real data renders with no actual runtime errors.
1401 lines
81 KiB
TypeScript
1401 lines
81 KiB
TypeScript
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 { 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 GradientButton from "./GradientButton";
|
|
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]);
|
|
|
|
// 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]);
|
|
|
|
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]);
|
|
|
|
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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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.5 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 700 }}>{t("jobTableOpen")}</Typography>
|
|
<Typography variant="h5" sx={{ fontWeight: 800, letterSpacing: "-0.01em" }}>{title}</Typography>
|
|
</Box>
|
|
{job && <Chip label={job.status} color={statusChipColor(job.status)} sx={{ fontWeight: 700, borderRadius: 2 }} />}
|
|
</Box>
|
|
</DialogTitle>
|
|
|
|
<DialogContent>
|
|
<JobFlowBar job={job} history={history} />
|
|
<Box
|
|
sx={{
|
|
mt: 1.5,
|
|
mb: 2,
|
|
p: 2,
|
|
borderRadius: 4,
|
|
backgroundColor: "background.paper",
|
|
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
|
|
</Box>
|
|
<Tabs
|
|
value={tab}
|
|
onChange={(_, v) => setTab(v)}
|
|
sx={{ mb: 2, "& .MuiTab-root": { fontWeight: 600, textTransform: "none", minHeight: 44 } }}
|
|
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" sx={{ fontWeight: 700 }}>{t("jobDetailsStrategySnapshot")}</Typography>
|
|
<GradientButton size="small" 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")}</GradientButton>
|
|
</Box>
|
|
{candidateFit || focusPlan ? (
|
|
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 4, backgroundColor: "background.paper", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
|
<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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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} />
|
|
{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>
|
|
{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>
|
|
{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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", 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>
|
|
);
|
|
}
|