Add focus plans and stage-aware follow-up drafting

This commit is contained in:
cesnimda
2026-03-23 22:04:39 +01:00
parent 19b0424ef3
commit 8db620e45b
8 changed files with 345 additions and 25 deletions
@@ -55,6 +55,67 @@ namespace JobTrackerApi.Controllers
return "Hi there,";
}
private async Task<List<string>> 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<string>
{
$"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<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
{
var normalized = (status ?? string.Empty).Trim();
var advice = new List<string>();
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<string> 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<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record FocusPlanDto(
List<string> ImmediatePriorities,
List<string> CvBulletIdeas,
List<string> ProofPointsToLeadWith,
List<string> CoverLetterAngles,
List<string> FollowUpApproach,
string StrategicSummary);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
@@ -1391,6 +1459,86 @@ Candidate CV/profile:
RecruiterMessageDraft: recruiterMessageDraft));
}
[HttpGet("{id:int}/focus-plan")]
public async Task<ActionResult<FocusPlanDto>> 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<string>();
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<ActionResult<InterviewPrepDto>> 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<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, CancellationToken cancellationToken)
public async Task<ActionResult<FollowUpDraftDto>> 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
@@ -91,6 +91,34 @@ public sealed class ProfileCvController : ControllerBase
return Ok(new { imported = true, characters = text.Length });
}
[HttpPost("rebuild")]
public async Task<IActionResult> 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<IActionResult> Improve()
{
@@ -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",
};
}
}
@@ -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<CandidateFit | null>(null);
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
@@ -96,14 +99,17 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
const [generationMode, setGenerationMode] = useState<GenerationMode>("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<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`).then((r) => {
api.get<FollowUpDraft>(`/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<FocusPlanResponse>(`/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<InterviewPrepResponse>(`/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<ReadinessResponse>(`/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
<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}
@@ -364,8 +378,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
<Box>
{loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box><Typography variant="overline">{t("jobDetailsReason")}</Typography><Typography>{followUpDraft.reason}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsSuggestedSendDate")}</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<Box><Typography variant="overline">{t("jobDetailsReason")}</Typography><Typography>{followUpDraft.reason}</Typography></Box>
<Box><Typography variant="overline">{t("jobDetailsSuggestedSendDate")}</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
</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>
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} />
<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)} />
@@ -419,6 +448,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
)}
{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 }}>
@@ -430,7 +472,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
</Box>
)}
{tab === 7 && (
{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 }}>
@@ -448,7 +490,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0
</Box>
)}
{tab === 8 && isAdmin && (
{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>
+5 -2
View File
@@ -148,6 +148,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
const [detailsFollowUpMode, setDetailsFollowUpMode] = useState<string | undefined>(undefined);
const [editJobId, setEditJobId] = useState<number | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(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
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); }} />
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} />
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
+38
View File
@@ -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",
+22 -2
View File
@@ -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<File | null>(null);
const [cropOpen, setCropOpen] = useState(false);
@@ -248,12 +249,31 @@ export default function ProfilePage() {
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv} onClick={() => cvInputRef.current?.click()}>
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv}
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
const res = await api.post<{ text?: string }>("/profile-cv/rebuild");
if (res.data?.text) setProfileCvText(res.data.text);
await loadProfile();
toast(t("profileCvRebuilt"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
} finally {
setRebuildingCv(false);
}
}}
>
{rebuildingCv ? t("profileCvRebuilding") : t("profileCvRebuild")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
+9
View File
@@ -75,6 +75,15 @@ export interface CandidateFit {
recruiterMessageDraft?: string | null;
}
export interface FocusPlanResponse {
immediatePriorities: string[];
cvBulletIdeas: string[];
proofPointsToLeadWith: string[];
coverLetterAngles: string[];
followUpApproach: string[];
strategicSummary: string;
}
export interface InterviewPrepResponse {
summary: string;
talkingPoints: string[];