From 8db620e45b92e1e7ede6cf727de3e3989b38f71f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 23 Mar 2026 22:04:39 +0100 Subject: [PATCH] Add focus plans and stage-aware follow-up drafting --- .../Controllers/JobApplicationsController.cs | 180 +++++++++++++++++- .../Controllers/ProfileCvController.cs | 28 +++ .../Services/FollowUpReminderHostedService.cs | 16 +- .../src/components/JobDetailsDialog.tsx | 68 +++++-- job-tracker-ui/src/components/JobTable.tsx | 7 +- job-tracker-ui/src/i18n/translations.ts | 38 ++++ job-tracker-ui/src/pages/ProfilePage.tsx | 24 ++- job-tracker-ui/src/types.ts | 9 + 8 files changed, 345 insertions(+), 25 deletions(-) diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 56135b6..aa064d7 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -55,6 +55,67 @@ namespace JobTrackerApi.Controllers return "Hi there,"; } + private async Task> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix) + { + var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70); + var items = (raw ?? string.Empty) + .Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim().TrimStart('-', '•', '*', ' ')) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(5) + .ToList(); + + if (items.Count > 0) return items; + + return new List + { + $"Lead with clear evidence tied to {fallbackPrefix}.", + "Use concrete outcomes, metrics, or scope whenever possible.", + "Keep the language specific to this role instead of generic.", + }; + } + + private static List BuildFollowUpApproach(string status, List matchedTags, List missingTags) + { + var normalized = (status ?? string.Empty).Trim(); + var advice = new List(); + + switch (normalized) + { + case "Applied": + advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied."); + advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background."); + break; + case "Waiting": + advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific."); + advice.Add("Use one proof point that shows why you remain a strong fit."); + break; + case "Interview": + case "Interviewing": + advice.Add("Focus on momentum, appreciation, and readiness for the next step."); + advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible."); + break; + case "Offer": + advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing."); + advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress."); + break; + case "Rejected": + advice.Add("If appropriate, ask for feedback with a respectful and concise tone."); + advice.Add("Keep the door open for future opportunities instead of arguing the decision."); + break; + default: + advice.Add("Match the tone to the current stage and be specific about why you are following up now."); + advice.Add("Keep it concise, credible, and easy to respond to."); + break; + } + + if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}."); + if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly."); + + return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList(); + } + private static IEnumerable SplitTags(string? s) { if (string.IsNullOrWhiteSpace(s)) yield break; @@ -1245,6 +1306,13 @@ namespace JobTrackerApi.Controllers public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); public sealed record DuplicateCheckResult(bool HasDuplicates, List Matches); public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn); + public sealed record FocusPlanDto( + List ImmediatePriorities, + List CvBulletIdeas, + List ProofPointsToLeadWith, + List CoverLetterAngles, + List FollowUpApproach, + string StrategicSummary); public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt); public sealed record TagTrendResponse(List Months, List Series); public sealed record CandidateFitChannelGuidanceDto(List Cv, List CoverLetter, List Interview, List RecruiterMessage); @@ -1391,6 +1459,86 @@ Candidate CV/profile: RecruiterMessageDraft: recruiterMessageDraft)); } + [HttpGet("{id:int}/focus-plan")] + public async Task> GetFocusPlan([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var userId = CurrentUserId; + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + + var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var cvText = user?.ProfileCvText; + if (string.IsNullOrWhiteSpace(cvText)) + { + return BadRequest("Add your profile CV text on the Profile page before generating a focus plan."); + } + + var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary } + .Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(jobText)) + { + return BadRequest("This job does not have enough description or notes to generate a focus plan."); + } + + var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(); + var normalizedCv = cvText.ToLowerInvariant(); + var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList(); + var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList(); + + var context = $@"Job title: {job.JobTitle} +Company: {job.Company?.Name} +Status: {job.Status} +Job description and notes: +{jobText} + +Candidate master CV: +{cvText}"; + + var strategicSummary = await _summarizer.SummarizeSectionAsync( + "Write a concise strategy summary for how the candidate should approach this role. Focus on what matters most in the posting, what evidence to lead with, and where to be careful.", + context, + 220, + 90) ?? "Focus on the strongest overlap with the posting, lead with evidence, and keep your outreach specific and credible."; + + var immediatePriorities = new List(); + immediatePriorities.AddRange(matchedTags.Take(3).Select(x => $"Lead with your strongest evidence for {x}.")); + immediatePriorities.AddRange(missingTags.Take(2).Select(x => $"Address {x} carefully: show adjacent experience or a credible ramp-up story.")); + if (!string.IsNullOrWhiteSpace(job.ShortSummary)) immediatePriorities.Add($"Use the role summary as a framing line: {job.ShortSummary.Trim().TrimEnd('.')}. "); + immediatePriorities = immediatePriorities.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList(); + + var cvBulletIdeas = await BuildListFromAiAsync( + "Write 4 resume bullet ideas tailored to this job. Each bullet should be specific, factual in tone, and outcome-oriented. Return one bullet per line with no numbering.", + context, + cancellationToken, + fallbackPrefix: matchedTags.FirstOrDefault() ?? job.JobTitle); + + var proofPointsToLeadWith = await BuildListFromAiAsync( + "Write 4 short proof points the candidate should lead with for this role. Use evidence, scope, outcomes, and credibility. Return one point per line with no numbering.", + context, + cancellationToken, + fallbackPrefix: job.Company?.Name ?? job.JobTitle); + + var coverLetterAngles = await BuildListFromAiAsync( + "Write 4 short cover-letter angles for this role. Focus on why this role, why this company, and the most relevant strengths. Return one angle per line with no numbering.", + context, + cancellationToken, + fallbackPrefix: matchedTags.FirstOrDefault() ?? "relevant experience"); + + var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags); + + return Ok(new FocusPlanDto( + ImmediatePriorities: immediatePriorities, + CvBulletIdeas: cvBulletIdeas, + ProofPointsToLeadWith: proofPointsToLeadWith, + CoverLetterAngles: coverLetterAngles, + FollowUpApproach: followUpApproach, + StrategicSummary: strategicSummary)); + } + [HttpGet("{id:int}/interview-prep")] public async Task> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken) { @@ -1529,10 +1677,10 @@ Candidate master CV: 120) ?? cvText; var coverLetterDraft = await _summarizer.SummarizeSectionAsync( - "Write a concise, tailored cover letter for this candidate and job. Keep it specific, credible, and directly aligned to the role.", + "Write a concise but high-quality cover letter for this candidate and job. Use the candidate CV as the source of evidence, mirror the priorities of the posting, mention concrete overlap instead of generic enthusiasm, and make the letter feel specific to this company and role. Keep it credible, polished, and directly aligned to the role.", packageContext, - 220, - 90); + 260, + 110); var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync( "Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words.", @@ -1756,7 +1904,7 @@ Candidate master CV: } [HttpGet("{id:int}/followup-draft")] - public async Task> GetFollowUpDraft([FromRoute] int id, CancellationToken cancellationToken) + public async Task> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken) { var job = await _db.JobApplications .AsNoTracking() @@ -1785,10 +1933,19 @@ Candidate master CV: var tagHighlights = SplitTags(job.Tags).Take(4).ToList(); var companyName = job.Company?.Name ?? "your team"; + var requestedMode = string.IsNullOrWhiteSpace(mode) + ? (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) ? "post-interview" + : job.Status == "Waiting" ? "waiting-update" + : job.Status == "Offer" ? "offer-checkin" + : job.Status == "Rejected" ? "feedback-request" + : "post-apply") + : mode.Trim().ToLowerInvariant(); var aiContext = $@"Candidate name: {signerName} Role: {job.JobTitle} Company: {companyName} Applied on: {appliedDate} +Current status: {job.Status} +Requested follow-up mode: {requestedMode} Reason for follow-up: {reason} Last message subject: {lastMessage?.Subject ?? "None"} Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")} @@ -1798,15 +1955,24 @@ Job description: {job.TranslatedDescription ?? job.Description ?? "No job description available."}"; var aiBody = await _summarizer.SummarizeSectionAsync( - "Write a concise, professional follow-up email in first person. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Keep it specific, warm, and under 140 words. Return only the email body.", + $"Write a concise, professional follow-up email in first person for the mode '{requestedMode}'. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Adjust the tone to the stage: post-apply should be light and interested, waiting-update should ask about progress, post-interview should thank them and reaffirm fit, offer-checkin should be warm and practical, feedback-request should be respectful and brief. Keep it specific, warm, and under 140 words. Return only the email body.", aiContext, - 180, + 190, 70); + var fallbackIntro = requestedMode switch + { + "post-interview" => $"I wanted to thank you again for the conversation about the {job.JobTitle} role and follow up on next steps.", + "waiting-update" => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}, and see whether there are any updates on the process.", + "offer-checkin" => $"I wanted to check in on the latest status for the {job.JobTitle} role and any next steps you would like from me.", + "feedback-request" => $"Thank you for the update on the {job.JobTitle} process. If you're open to it, I would be grateful for any brief feedback that could help me improve.", + _ => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.", + }; + var fallbackBody = string.Join("\n\n", new[] { greeting, - $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.", + fallbackIntro, !string.IsNullOrWhiteSpace(summary) ? $"From the posting and my background, the strongest overlap seems to be {summary.Trim().TrimEnd('.')}." : tagHighlights.Count > 0 diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 18c2f9e..c8d6619 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -91,6 +91,34 @@ public sealed class ProfileCvController : ControllerBase return Ok(new { imported = true, characters = text.Length }); } + [HttpPost("rebuild")] + public async Task Rebuild() + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rebuilding it."); + + var rebuilt = await _aiService.SummarizeSectionAsync( + "Rewrite this CV into a stronger master CV with clear sections such as Professional Summary, Core Skills, Experience Highlights, and Selected Achievements. Preserve only factual claims, avoid inventing employers or metrics, and make the output clean and ready for tailoring to job applications. Return only the rebuilt CV text.", + user.ProfileCvText, + 2200, + 700); + + if (string.IsNullOrWhiteSpace(rebuilt)) + { + return BadRequest("The AI service could not rebuild your CV text right now."); + } + + user.ProfileCvText = rebuilt.Trim(); + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return Ok(new { rebuilt = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText }); + } + [HttpPost("improve")] public async Task Improve() { diff --git a/JobTrackerApi/Services/FollowUpReminderHostedService.cs b/JobTrackerApi/Services/FollowUpReminderHostedService.cs index 151adaa..bd9290f 100644 --- a/JobTrackerApi/Services/FollowUpReminderHostedService.cs +++ b/JobTrackerApi/Services/FollowUpReminderHostedService.cs @@ -86,7 +86,8 @@ public sealed class FollowUpReminderHostedService : BackgroundService if (owner is null || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue; var reason = BuildReminderReason(job, decision.Reason, upcoming); - var detailsUrl = $"{baseUrl}/jobs?open={job.Id}&tab=4"; + var followMode = SuggestFollowUpMode(job.Status); + var detailsUrl = $"{baseUrl}/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}"; var companyName = job.Company?.Name ?? "Unknown company"; var appliedOn = job.DateApplied.ToString("MMMM d, yyyy"); var subject = $"Follow up reminder: {job.JobTitle} at {companyName}"; @@ -124,4 +125,17 @@ public sealed class FollowUpReminderHostedService : BackgroundService _ => "the application may need attention" }; } + + private static string SuggestFollowUpMode(string? status) + { + return (status ?? string.Empty).Trim() switch + { + "Waiting" => "waiting-update", + "Interview" => "post-interview", + "Interviewing" => "post-interview", + "Offer" => "offer-checkin", + "Rejected" => "feedback-request", + _ => "post-apply", + }; + } } diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 90ccfed..bbc61a9 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -19,7 +19,7 @@ import { } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; @@ -42,6 +42,7 @@ interface Props { jobId: number | null; onClose: () => void; initialTab?: number; + initialFollowUpMode?: string; } function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" { @@ -70,7 +71,7 @@ function copyLines(items: string[]) { return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n")); } -export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 }: Props) { +export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) { const { toast } = useToast(); const { t } = useI18n(); const { confirmAction } = useDialogActions(); @@ -84,7 +85,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 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 [interviewPrep, setInterviewPrep] = useState(null); const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false); const [readiness, setReadiness] = useState(null); @@ -96,14 +99,17 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 const [generationMode, setGenerationMode] = useState("default"); const [tailoredCvText, setTailoredCvText] = useState(""); const [draftRecipient, setDraftRecipient] = useState(""); + const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply"); + const [draftReloadToken, setDraftReloadToken] = useState(0); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); useEffect(() => { if (!open || !jobId) return; - setTab(Math.max(0, Math.min(8, initialTab))); + setTab(Math.max(0, Math.min(9, initialTab))); setFollowUpDraft(null); setCandidateFit(null); + setFocusPlan(null); setInterviewPrep(null); setReadiness(null); setApplicationPackage(null); @@ -111,20 +117,21 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 setJob(r.data); setTailoredCvText(r.data.tailoredCvText ?? ""); 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(`/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]); + }, [open, jobId, initialTab, initialFollowUpMode]); useEffect(() => { - if (!open || !jobId || tab !== 4 || followUpDraft) return; + if (!open || !jobId || tab !== 4) return; setLoadingDraft(true); - api.get(`/jobapplications/${jobId}/followup-draft`).then((r) => { + api.get(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode } }).then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); - }, [open, jobId, tab, followUpDraft]); + }, [open, jobId, tab, followUpMode, draftReloadToken]); useEffect(() => { if (!open || !jobId || tab !== 5 || candidateFit) return; @@ -133,13 +140,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 }, [open, jobId, tab, candidateFit]); useEffect(() => { - if (!open || !jobId || tab !== 6 || interviewPrep) return; + if (!open || !jobId || tab !== 6 || focusPlan) return; + setLoadingFocusPlan(true); + api.get(`/jobapplications/${jobId}/focus-plan`).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); + }, [open, jobId, tab, focusPlan]); + + useEffect(() => { + if (!open || !jobId || tab !== 7 || interviewPrep) return; setLoadingInterviewPrep(true); api.get(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); }, [open, jobId, tab, interviewPrep]); useEffect(() => { - if (!open || !jobId || tab !== 7 || readiness) return; + 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]); @@ -193,6 +206,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 + {isAdmin ? : null} @@ -364,8 +378,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 {loadingDraft ? : followUpDraft ? ( - {t("jobDetailsReason")}{followUpDraft.reason} - {t("jobDetailsSuggestedSendDate")}{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()} + + {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)} /> @@ -419,6 +448,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 )} {tab === 6 && ( + + {loadingFocusPlan ? : focusPlan ? ( + + + + + + + ) : {t("jobDetailsNoFocusPlan")}} + + )} + + {tab === 7 && ( {loadingInterviewPrep ? : interviewPrep ? ( @@ -430,7 +472,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 )} - {tab === 7 && ( + {tab === 8 && ( {loadingReadiness ? : readiness ? ( @@ -448,7 +490,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 )} - {tab === 8 && isAdmin && ( + {tab === 9 && isAdmin && ( {history.length === 0 ? {t("jobDetailsNoHistory")} : history.map((entry) => )} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index c6a7aa9..dc5ba90 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -148,6 +148,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const [companyFilterId, setCompanyFilterId] = useState("All"); const [detailsJobId, setDetailsJobId] = useState(null); const [detailsInitialTab, setDetailsInitialTab] = useState(0); + const [detailsFollowUpMode, setDetailsFollowUpMode] = useState(undefined); const [editJobId, setEditJobId] = useState(null); const [reloadToken, setReloadToken] = useState(0); const [statusAnchor, setStatusAnchor] = useState(null); @@ -181,11 +182,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const paramsSearch = new URLSearchParams(location.search); const openId = Number(paramsSearch.get("open") || 0); const tabIndex = Number(paramsSearch.get("tab") || 0); + const followMode = paramsSearch.get("followMode") || undefined; if (!openId || jobs.length === 0) return; const job = jobs.find((j) => j.id === openId); if (!job) return; setDetailsJobId(openId); - setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(8, tabIndex)) : 0); + setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(9, tabIndex)) : 0); + setDetailsFollowUpMode(followMode); paramsSearch.delete("open"); paramsSearch.delete("tab"); navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true }); @@ -413,7 +416,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} /> - { setDetailsJobId(null); setDetailsInitialTab(0); }} /> + { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} /> setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> {(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })})} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 15294e5..3f99667 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -176,6 +176,10 @@ export const translations = { profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.", profileUploadCv: "Upload CV", profileCvImprove: "Improve CV text", + profileCvRebuild: "Rebuild CV structure", + profileCvRebuilding: "Rebuilding CV...", + profileCvRebuilt: "CV rebuilt into a cleaner structure.", + profileCvRebuildFailed: "Failed to rebuild CV text.", profileCvImproving: "Improving CV...", profileCvImproved: "CV text improved.", profileCvImproveFailed: "Failed to improve CV text.", @@ -617,6 +621,7 @@ export const translations = { jobDetailsTabAttachments: "Attachments", jobDetailsTabTailoredCv: "Tailored CV", jobDetailsTabCandidateFit: "Candidate fit", + jobDetailsTabFocusPlan: "Focus plan", jobDetailsTabInterviewPrep: "Interview prep", jobDetailsTabHistory: "History", jobDetailsTailoredCvMode: "Generation mode", @@ -677,6 +682,13 @@ export const translations = { jobDetailsRecruiterMessageSaveFailed: "Failed to save recruiter message.", jobDetailsKeyPoints: "Key points to emphasize", jobDetailsReason: "Reason", + jobDetailsFollowUpMode: "Follow-up mode", + jobDetailsFollowUpModePostApply: "Post-apply check-in", + jobDetailsFollowUpModeWaiting: "Waiting for update", + jobDetailsFollowUpModePostInterview: "Post-interview follow-up", + jobDetailsFollowUpModeOffer: "Offer / decision check-in", + jobDetailsFollowUpModeFeedback: "Feedback request", + jobDetailsRegenerateDraft: "Regenerate draft", jobDetailsSuggestedSendDate: "Suggested send date", jobDetailsRecipient: "Recipient", jobDetailsRecipientHelp: "Defaults to the company recruiter email when available.", @@ -701,6 +713,13 @@ export const translations = { jobDetailsRecruiterMessageGuidance: "Recruiter message guidance", jobDetailsNoDraftAvailableYet: "No draft available yet.", jobDetailsCandidateFitEmpty: "Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.", + jobDetailsFocusSummary: "Strategy summary", + jobDetailsImmediatePriorities: "Immediate priorities", + jobDetailsCvBulletIdeas: "CV bullet ideas", + jobDetailsProofPoints: "Proof points to lead with", + jobDetailsCoverLetterAngles: "Cover-letter angles", + jobDetailsFollowUpApproach: "Follow-up approach", + jobDetailsNoFocusPlan: "No focus plan available yet.", jobDetailsInterviewPrepBrief: "Interview prep brief", jobDetailsTalkingPoints: "Talking points", jobDetailsLikelyQuestions: "Likely questions", @@ -901,6 +920,10 @@ export const translations = { profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.", profileUploadCv: "Last opp CV", profileCvImprove: "Forbedre CV-tekst", + profileCvRebuild: "Bygg opp CV-struktur på nytt", + profileCvRebuilding: "Bygger opp CV på nytt...", + profileCvRebuilt: "CV bygget opp i en renere struktur.", + profileCvRebuildFailed: "Kunne ikke bygge opp CV-tekst på nytt.", profileCvImproving: "Forbedrer CV...", profileCvImproved: "CV-tekst forbedret.", profileCvImproveFailed: "Kunne ikke forbedre CV-tekst.", @@ -1342,6 +1365,7 @@ export const translations = { jobDetailsTabAttachments: "Vedlegg", jobDetailsTabTailoredCv: "Tilpasset CV", jobDetailsTabCandidateFit: "Kandidatmatch", + jobDetailsTabFocusPlan: "Fokusplan", jobDetailsTabInterviewPrep: "Intervjuforberedelse", jobDetailsTabHistory: "Historikk", jobDetailsTailoredCvMode: "Genereringsmodus", @@ -1402,6 +1426,13 @@ export const translations = { jobDetailsRecruiterMessageSaveFailed: "Kunne ikke lagre melding til rekrutterer.", jobDetailsKeyPoints: "Nøkkelpunkter å fremheve", jobDetailsReason: "Årsak", + jobDetailsFollowUpMode: "Oppfølgingsmodus", + jobDetailsFollowUpModePostApply: "Oppfølging etter søknad", + jobDetailsFollowUpModeWaiting: "Venter på oppdatering", + jobDetailsFollowUpModePostInterview: "Oppfølging etter intervju", + jobDetailsFollowUpModeOffer: "Tilbud / beslutningsoppfølging", + jobDetailsFollowUpModeFeedback: "Be om tilbakemelding", + jobDetailsRegenerateDraft: "Generer utkast på nytt", jobDetailsSuggestedSendDate: "Foreslått sendingsdato", jobDetailsRecipient: "Mottaker", jobDetailsRecipientHelp: "Bruker selskapets rekrutterer-e-post som standard når den finnes.", @@ -1426,6 +1457,13 @@ export const translations = { jobDetailsRecruiterMessageGuidance: "Veiledning for rekrutterermelding", jobDetailsNoDraftAvailableYet: "Ingen utkast tilgjengelig ennå.", jobDetailsCandidateFitEmpty: "Legg til CV-teksten din på profilsiden for å generere en kandidatmatchanalyse for denne rollen.", + jobDetailsFocusSummary: "Strategioppsummering", + jobDetailsImmediatePriorities: "Viktigste prioriteringer nå", + jobDetailsCvBulletIdeas: "Ideer til CV-punkter", + jobDetailsProofPoints: "Bevispunkter å lede med", + jobDetailsCoverLetterAngles: "Vinkler for søknadsbrev", + jobDetailsFollowUpApproach: "Oppfølgingsstrategi", + jobDetailsNoFocusPlan: "Ingen fokusplan tilgjengelig ennå.", jobDetailsInterviewPrepBrief: "Kort intervjuforberedelse", jobDetailsTalkingPoints: "Samtalepunkter", jobDetailsLikelyQuestions: "Sannsynlige spørsmål", diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 8d8a354..7b949af 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -52,6 +52,7 @@ export default function ProfilePage() { const [loading, setLoading] = useState(false); const [uploadingCv, setUploadingCv] = useState(false); const [improvingCv, setImprovingCv] = useState(false); + const [rebuildingCv, setRebuildingCv] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [avatarFile, setAvatarFile] = useState(null); const [cropOpen, setCropOpen] = useState(false); @@ -248,12 +249,31 @@ export default function ProfilePage() { } }} /> - +