feat: add application package generation and grouped readiness workflows
This commit is contained in:
@@ -32,7 +32,7 @@ public sealed class AuthController : ControllerBase
|
||||
public IActionResult Config()
|
||||
{
|
||||
var requireAuth = _cfg.GetValue("Auth:Require", false);
|
||||
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? "").Trim());
|
||||
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
|
||||
return Ok(new
|
||||
@@ -40,7 +40,7 @@ public sealed class AuthController : ControllerBase
|
||||
requireAuth,
|
||||
googleEnabled,
|
||||
localEnabled = true,
|
||||
allowRegistration
|
||||
allowRegistration,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,17 +56,18 @@ public sealed class AuthController : ControllerBase
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? DisplayName,
|
||||
string? ProfileCvText,
|
||||
IList<string> Roles,
|
||||
GoogleLinkDto? GoogleLink);
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName);
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText);
|
||||
public sealed record GoogleTokenRequest(string Token);
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? "").Trim();
|
||||
var password = request.Password ?? "";
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
var password = request.Password ?? string.Empty;
|
||||
|
||||
if (email.Length == 0) return BadRequest("Email is required.");
|
||||
if (password.Length == 0) return BadRequest("Password is required.");
|
||||
@@ -88,8 +89,8 @@ public sealed class AuthController : ControllerBase
|
||||
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
if (!allow) return StatusCode(403, "Registration is disabled.");
|
||||
|
||||
var email = (request.Email ?? "").Trim();
|
||||
var password = request.Password ?? "";
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
var password = request.Password ?? string.Empty;
|
||||
|
||||
if (email.Length == 0) return BadRequest("Email is required.");
|
||||
if (password.Length == 0) return BadRequest("Password is required.");
|
||||
@@ -112,7 +113,7 @@ public sealed class AuthController : ControllerBase
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = (request.Token ?? "").Trim();
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Google token is required.");
|
||||
|
||||
GoogleTokenPrincipal google;
|
||||
@@ -150,16 +151,16 @@ public sealed class AuthController : ControllerBase
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||
{
|
||||
var u = await _users.GetUserAsync(User);
|
||||
if (u is not null)
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is not null)
|
||||
{
|
||||
var roles = await _users.GetRolesAsync(u);
|
||||
return Ok(ToMeResult(u, roles));
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
return Ok(ToMeResult(user, roles));
|
||||
}
|
||||
|
||||
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
|
||||
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
var iss = User.FindFirstValue("iss") ?? "";
|
||||
var iss = User.FindFirstValue("iss") ?? string.Empty;
|
||||
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
|
||||
|
||||
return Ok(new MeResult(
|
||||
@@ -170,6 +171,7 @@ public sealed class AuthController : ControllerBase
|
||||
FirstName: User.FindFirstValue("given_name"),
|
||||
LastName: User.FindFirstValue("family_name"),
|
||||
DisplayName: User.FindFirstValue("name"),
|
||||
ProfileCvText: null,
|
||||
Roles: Array.Empty<string>(),
|
||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
|
||||
}
|
||||
@@ -178,8 +180,8 @@ public sealed class AuthController : ControllerBase
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
||||
{
|
||||
var u = await _users.GetUserAsync(User);
|
||||
if (u is null)
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
||||
}
|
||||
@@ -189,14 +191,16 @@ public sealed class AuthController : ControllerBase
|
||||
var firstName = TrimOrNull(request.FirstName);
|
||||
var lastName = TrimOrNull(request.LastName);
|
||||
var displayName = TrimOrNull(request.DisplayName);
|
||||
var profileCvText = TrimOrNull(request.ProfileCvText);
|
||||
|
||||
if (email is not null) u.Email = email;
|
||||
if (userName is not null) u.UserName = userName;
|
||||
u.FirstName = firstName;
|
||||
u.LastName = lastName;
|
||||
u.DisplayName = displayName;
|
||||
if (email is not null) user.Email = email;
|
||||
if (userName is not null) user.UserName = userName;
|
||||
user.FirstName = firstName;
|
||||
user.LastName = lastName;
|
||||
user.DisplayName = displayName;
|
||||
user.ProfileCvText = profileCvText;
|
||||
|
||||
var res = await _users.UpdateAsync(u);
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
@@ -213,7 +217,7 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var token = (request.Token ?? "").Trim();
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Google token is required.");
|
||||
|
||||
GoogleTokenPrincipal google;
|
||||
@@ -279,8 +283,8 @@ public sealed class AuthController : ControllerBase
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var u = await _users.GetUserAsync(User);
|
||||
if (u is null)
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return StatusCode(501, "Password changes are only supported for local username/password accounts.");
|
||||
}
|
||||
@@ -288,7 +292,7 @@ public sealed class AuthController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return BadRequest("CurrentPassword is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword)) return BadRequest("NewPassword is required.");
|
||||
|
||||
var res = await _users.ChangePasswordAsync(u, request.CurrentPassword, request.NewPassword);
|
||||
var res = await _users.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
@@ -301,7 +305,7 @@ public sealed class AuthController : ControllerBase
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? "").Trim();
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
if (email.Length == 0) return NoContent();
|
||||
|
||||
var user = await _users.FindByEmailAsync(email);
|
||||
@@ -312,7 +316,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
var token = await _users.GeneratePasswordResetTokenAsync(user);
|
||||
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
@@ -336,9 +340,9 @@ public sealed class AuthController : ControllerBase
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
|
||||
{
|
||||
var email = (request.Email ?? "").Trim();
|
||||
var token = request.Token ?? "";
|
||||
var newPassword = request.NewPassword ?? "";
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
var token = request.Token ?? string.Empty;
|
||||
var newPassword = request.NewPassword ?? string.Empty;
|
||||
|
||||
if (email.Length == 0) return BadRequest("Email is required.");
|
||||
if (token.Length == 0) return BadRequest("Token is required.");
|
||||
@@ -369,6 +373,7 @@ public sealed class AuthController : ControllerBase
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
DisplayName: user.DisplayName,
|
||||
ProfileCvText: user.ProfileCvText,
|
||||
Roles: roles,
|
||||
GoogleLink: new GoogleLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||
@@ -376,5 +381,3 @@ public sealed class AuthController : ControllerBase
|
||||
LinkedAt: user.GoogleLinkedAt));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -461,6 +461,25 @@ namespace JobTrackerApi.Controllers
|
||||
var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo;
|
||||
if (!d.NeedsFollowUp && !upcoming) continue;
|
||||
var shortSummary = j.ShortSummary;
|
||||
var reminderReason = d.Reason;
|
||||
if (string.IsNullOrWhiteSpace(j.TailoredCvText))
|
||||
{
|
||||
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
|
||||
? "Tailored CV missing for an active role."
|
||||
: $"{reminderReason} Tailored CV missing.";
|
||||
}
|
||||
if (j.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(j.Notes))
|
||||
{
|
||||
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
|
||||
? "Interview coming up but prep notes are missing."
|
||||
: $"{reminderReason} Interview prep notes missing.";
|
||||
}
|
||||
if (!j.ResponseReceived && j.FollowUpAt is null)
|
||||
{
|
||||
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
|
||||
? "No response yet and no follow-up date is scheduled."
|
||||
: $"{reminderReason} No follow-up date is scheduled.";
|
||||
}
|
||||
|
||||
dtos.Add(new JobApplicationDto(
|
||||
Id: j.Id,
|
||||
@@ -492,7 +511,7 @@ namespace JobTrackerApi.Controllers
|
||||
DeletedAt: j.DeletedAt,
|
||||
DaysSince: j.DaysSince,
|
||||
NeedsFollowUp: d.NeedsFollowUp,
|
||||
FollowUpReason: d.Reason,
|
||||
FollowUpReason: reminderReason,
|
||||
ShortSummary: shortSummary,
|
||||
FullSummary: null
|
||||
));
|
||||
@@ -1186,6 +1205,290 @@ namespace JobTrackerApi.Controllers
|
||||
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
|
||||
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);
|
||||
public sealed record CandidateFitDto(
|
||||
string MatchSummary,
|
||||
string FitLevel,
|
||||
int MatchScore,
|
||||
List<string> Strengths,
|
||||
List<string> Gaps,
|
||||
List<string> Mention,
|
||||
List<string> Avoid,
|
||||
List<string> CvImprovements,
|
||||
List<string> MissingKeywords,
|
||||
List<string> InterviewPrep,
|
||||
string TailoredPitch,
|
||||
CandidateFitChannelGuidanceDto Guidance,
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
|
||||
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
||||
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
|
||||
|
||||
[HttpGet("{id:int}/candidate-fit")]
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([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 running candidate fit analysis.");
|
||||
}
|
||||
|
||||
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
if (string.IsNullOrWhiteSpace(jobText))
|
||||
{
|
||||
return BadRequest("This job does not have enough description or notes to compare against your CV.");
|
||||
}
|
||||
|
||||
var normalizedCv = cvText.ToLowerInvariant();
|
||||
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||
|
||||
var jobContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
|
||||
Job description and notes:
|
||||
{jobText}
|
||||
|
||||
Candidate CV/profile:
|
||||
{cvText}";
|
||||
|
||||
var matchSummary = await _summarizer.SummarizeSectionAsync(
|
||||
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
|
||||
jobContext,
|
||||
220,
|
||||
90) ?? "No fit summary available yet.";
|
||||
|
||||
var strengthCount = strengths.Count;
|
||||
var gapCount = gaps.Count;
|
||||
var rawScore = 35 + (strengthCount * 10) - (gapCount * 4);
|
||||
var matchScore = Math.Clamp(rawScore, 20, 96);
|
||||
var fitLevel = matchScore >= 75 ? "Strong match" : matchScore >= 55 ? "Potential match" : "Stretch role";
|
||||
|
||||
var mention = strengths.Select(x => $"Show evidence of {x} with concrete results and outcomes.").Take(5).ToList();
|
||||
if (!mention.Any() && jobTags.Any()) mention.Add($"Highlight directly relevant experience with {jobTags.First()}. ");
|
||||
|
||||
var avoid = new List<string>();
|
||||
if (gaps.Any())
|
||||
{
|
||||
avoid.AddRange(gaps.Take(4).Select(x => $"Do not overclaim deep expertise in {x} unless you can back it up with recent examples."));
|
||||
}
|
||||
avoid.Add("Avoid generic claims without metrics, outcomes, or ownership details.");
|
||||
|
||||
var cvImprovements = new List<string>();
|
||||
cvImprovements.AddRange(gaps.Take(4).Select(x => $"If you have experience with {x}, make it easier to find in your CV with a specific bullet and result."));
|
||||
cvImprovements.Add("Quantify impact with numbers, scope, speed, revenue, quality, or customer outcomes where possible.");
|
||||
cvImprovements.Add("Mirror the wording of the role where it is accurate, especially in your summary and recent experience.");
|
||||
|
||||
var missingKeywords = gaps.Take(6).ToList();
|
||||
var interviewPrep = new List<string>();
|
||||
interviewPrep.AddRange(strengths.Take(3).Select(x => $"Prepare a STAR example that proves your experience with {x}."));
|
||||
interviewPrep.AddRange(gaps.Take(2).Select(x => $"Prepare a credible learning story for {x}: related work, fast ramp-up, and how you would close the gap."));
|
||||
if (!interviewPrep.Any())
|
||||
{
|
||||
interviewPrep.Add("Prepare two strong examples showing measurable impact, collaboration, and delivery under constraints.");
|
||||
}
|
||||
|
||||
var tailoredPitch = await _summarizer.SummarizeSectionAsync(
|
||||
"Write a short tailored candidate pitch for this role in first person. Keep it practical and credible.",
|
||||
jobContext,
|
||||
120,
|
||||
45) ?? "I bring relevant experience, measurable outcomes, and a clear understanding of the role priorities.";
|
||||
|
||||
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
|
||||
"Draft a short cover letter opening and value proposition for this candidate and job. Keep it specific and credible.",
|
||||
jobContext,
|
||||
180,
|
||||
70);
|
||||
|
||||
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
||||
"Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.",
|
||||
jobContext,
|
||||
120,
|
||||
45);
|
||||
|
||||
var guidance = new CandidateFitChannelGuidanceDto(
|
||||
Cv: mention.Take(4).ToList(),
|
||||
CoverLetter: strengths.Take(3).Select(x => $"Connect {x} to why you are interested in this company and role now.").ToList(),
|
||||
Interview: interviewPrep.Take(5).ToList(),
|
||||
RecruiterMessage: new List<string>
|
||||
{
|
||||
$"Lead with your strongest overlap: {(strengths.FirstOrDefault() ?? jobTags.FirstOrDefault() ?? "relevant experience")}. ",
|
||||
"Keep the note concise and outcome-focused.",
|
||||
"Close with a clear expression of interest and availability."
|
||||
});
|
||||
|
||||
return Ok(new CandidateFitDto(
|
||||
MatchSummary: matchSummary,
|
||||
FitLevel: fitLevel,
|
||||
MatchScore: matchScore,
|
||||
Strengths: strengths,
|
||||
Gaps: gaps,
|
||||
Mention: mention,
|
||||
Avoid: avoid.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
|
||||
CvImprovements: cvImprovements.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
|
||||
MissingKeywords: missingKeywords,
|
||||
InterviewPrep: interviewPrep.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
|
||||
TailoredPitch: tailoredPitch,
|
||||
Guidance: guidance,
|
||||
CoverLetterDraft: coverLetterDraft,
|
||||
RecruiterMessageDraft: recruiterMessageDraft));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/interview-prep")]
|
||||
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([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 context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList();
|
||||
var likelyQuestions = tags.Take(4).Select(x => $"How have you applied {x} in practice, and what impact did it have?").ToList();
|
||||
var weakSpots = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(job.TailoredCvText)) weakSpots.Add("You have not saved a tailored CV for this role yet.");
|
||||
if (string.IsNullOrWhiteSpace(job.CoverLetterText)) weakSpots.Add("You do not have a saved cover letter draft for this role yet.");
|
||||
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.NextAction)) weakSpots.Add("Your next action is not clearly documented.");
|
||||
if (!weakSpots.Any()) weakSpots.Add("Prepare to explain why this role and company are a strong fit right now.");
|
||||
|
||||
var summary = await _summarizer.SummarizeSectionAsync(
|
||||
"Create a concise interview prep brief. Focus on strongest talking points, likely topics, and preparation priorities.",
|
||||
context,
|
||||
180,
|
||||
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
|
||||
|
||||
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/readiness")]
|
||||
public async Task<ActionResult<ReadinessDto>> GetReadiness([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 completed = new List<string>();
|
||||
var missing = new List<string>();
|
||||
var reminders = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role");
|
||||
if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft");
|
||||
if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example");
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible");
|
||||
if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear");
|
||||
if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date");
|
||||
|
||||
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV.");
|
||||
if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing.");
|
||||
if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled.");
|
||||
|
||||
var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
|
||||
var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
|
||||
|
||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv")]
|
||||
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
|
||||
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/generate-application-package")]
|
||||
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([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 an application package.");
|
||||
}
|
||||
|
||||
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 an application package.");
|
||||
}
|
||||
|
||||
var packageContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
|
||||
Job context:
|
||||
{jobText}
|
||||
|
||||
Candidate master CV:
|
||||
{cvText}";
|
||||
|
||||
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
|
||||
"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job.",
|
||||
packageContext,
|
||||
256,
|
||||
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.",
|
||||
packageContext,
|
||||
220,
|
||||
90);
|
||||
|
||||
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.",
|
||||
packageContext,
|
||||
170,
|
||||
70);
|
||||
|
||||
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
||||
"Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.",
|
||||
packageContext,
|
||||
120,
|
||||
45);
|
||||
|
||||
var keyPoints = SkillTagger.Detect(jobText)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(5)
|
||||
.Select(x => $"Lead with evidence of {x}.")
|
||||
.ToList();
|
||||
|
||||
return Ok(new GenerateApplicationPackageDto(
|
||||
TailoredCvText: tailoredCvText,
|
||||
CoverLetterDraft: coverLetterDraft,
|
||||
ApplicationAnswerDraft: applicationAnswerDraft,
|
||||
RecruiterMessageDraft: recruiterMessageDraft,
|
||||
KeyPoints: keyPoints));
|
||||
}
|
||||
|
||||
[HttpGet("analytics-overview")]
|
||||
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/profile-cv")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class ProfileCvController : ControllerBase
|
||||
{
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".txt",
|
||||
".md",
|
||||
".pdf",
|
||||
".docx",
|
||||
};
|
||||
|
||||
private const long MaxFileSizeBytes = 512 * 1024;
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
|
||||
public ProfileCvController(UserManager<ApplicationUser> users)
|
||||
{
|
||||
_users = users;
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (file is null || file.Length == 0) return BadRequest("Select a CV file to upload.");
|
||||
if (file.Length > MaxFileSizeBytes) return BadRequest("CV import file is too large.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
{
|
||||
return BadRequest("Only .txt, .md, .pdf, and .docx CV imports are supported right now.");
|
||||
}
|
||||
|
||||
var text = (await ExtractTextAsync(file, extension)).Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return BadRequest("The uploaded CV file could not be read or was empty.");
|
||||
}
|
||||
|
||||
user.ProfileCvText = text;
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return Ok(new { imported = true, characters = text.Length });
|
||||
}
|
||||
|
||||
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
|
||||
{
|
||||
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return (await reader.ReadToEndAsync()).Trim();
|
||||
}
|
||||
|
||||
await using var memory = new MemoryStream();
|
||||
await file.CopyToAsync(memory);
|
||||
var bytes = memory.ToArray();
|
||||
|
||||
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var raw = Encoding.UTF8.GetString(bytes);
|
||||
var scrubbed = Regex.Replace(raw, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " ");
|
||||
return Regex.Replace(scrubbed, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
if (string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(bytes), System.IO.Compression.ZipArchiveMode.Read, leaveOpen: false);
|
||||
var entry = archive.GetEntry("word/document.xml");
|
||||
if (entry is null) return string.Empty;
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream, Encoding.UTF8);
|
||||
var xml = await reader.ReadToEndAsync();
|
||||
var withoutTags = Regex.Replace(xml, "<[^>]+>", " ");
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
|
||||
return Regex.Replace(decoded, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -459,6 +459,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
|
||||
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
|
||||
@@ -547,6 +548,8 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
// Some dev DBs may not match the "legacy" fingerprint above but still lack
|
||||
// the ShortSummary column. Ensure it exists unconditionally if missing.
|
||||
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
|
||||
|
||||
// Ensure ownership columns exist even on non-legacy DBs.
|
||||
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
|
||||
@@ -35,6 +35,7 @@ namespace JobTrackerApi.Services
|
||||
public interface ISummarizerService
|
||||
{
|
||||
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
|
||||
Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
|
||||
Task RunProbeAsync(CancellationToken cancellationToken = default);
|
||||
Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -75,6 +76,19 @@ namespace JobTrackerApi.Services
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
return await SummarizeCoreAsync(text, maxLength, minLength);
|
||||
}
|
||||
|
||||
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
|
||||
|
||||
var composed = $"{instruction.Trim()}\n\n{text.Trim()}";
|
||||
return SummarizeCoreAsync(composed, maxLength, minLength);
|
||||
}
|
||||
|
||||
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
|
||||
{
|
||||
// Use a deterministic content hash instead of string.GetHashCode() so cache keys
|
||||
// are collision-resistant and stable across process restarts.
|
||||
var key = BuildCacheKey(text, maxLength, minLength);
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class ApplicationUser : IdentityUser
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? ProfileCvText { get; set; }
|
||||
public string? GoogleSubject { get; set; }
|
||||
public string? GoogleEmail { get; set; }
|
||||
public DateTimeOffset? GoogleLinkedAt { get; set; }
|
||||
|
||||
@@ -41,6 +41,8 @@ public class JobApplication
|
||||
public DateTime? Deadline { get; set; }
|
||||
// Short summary generated at creation time and persisted to avoid repeated model calls.
|
||||
public string? ShortSummary { get; set; }
|
||||
public string? TailoredCvText { get; set; }
|
||||
public DateTime? TailoredCvUpdatedAt { get; set; }
|
||||
|
||||
public List<Correspondence> Messages { get; set; } = new();
|
||||
public List<Attachment> Attachments { get; set; } = new();
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function DashboardView() {
|
||||
{ label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" },
|
||||
{ label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" },
|
||||
{ label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" },
|
||||
{ label: "In trash", value: stats?.deleted ?? "-", sub: "Soft-deleted" },
|
||||
{ label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" },
|
||||
];
|
||||
|
||||
const togglePref = (key: keyof Prefs) => {
|
||||
@@ -433,3 +433,13 @@ export default function DashboardView() {
|
||||
|
||||
|
||||
|
||||
cent summarizer errors recorded."}</Typography>
|
||||
</Paper>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
@@ -50,8 +51,21 @@ function statusChipColor(status: string): "default" | "primary" | "warning" | "e
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
|
||||
const [job, setJob] = useState<JobApplication | null>(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]);
|
||||
@@ -60,6 +74,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
const [refreshingAi, setRefreshingAi] = useState(false);
|
||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||
const [loadingCandidateFit, setLoadingCandidateFit] = 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 [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [draftSubject, setDraftSubject] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
@@ -68,27 +92,47 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
if (!open || !jobId) return;
|
||||
setTab(0);
|
||||
setFollowUpDraft(null);
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); });
|
||||
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([]));
|
||||
setCandidateFit(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
setApplicationPackage(null);
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
||||
setDraftRecipient(r.data.company?.recruiterEmail ?? "");
|
||||
});
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4 || followUpDraft) return;
|
||||
setLoadingDraft(true);
|
||||
api
|
||||
.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`)
|
||||
.then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); })
|
||||
.catch(() => setFollowUpDraft(null))
|
||||
.finally(() => setLoadingDraft(false));
|
||||
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`).then((r) => {
|
||||
setFollowUpDraft(r.data);
|
||||
setDraftSubject(r.data.subject);
|
||||
setDraftBody(r.data.body);
|
||||
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || candidateFit) return;
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || 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;
|
||||
setLoadingReadiness(true);
|
||||
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
|
||||
}, [open, jobId, tab, readiness]);
|
||||
|
||||
const tags: string[] = (() => {
|
||||
const raw = job?.tags;
|
||||
if (!raw) return [];
|
||||
@@ -102,9 +146,12 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
|
||||
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
|
||||
const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
|
||||
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
|
||||
const rawDescriptionText = job?.translatedDescription || job?.description || "";
|
||||
const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
@@ -118,89 +165,36 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<DialogContent>
|
||||
<JobFlowBar job={job} history={history} />
|
||||
<Box sx={{ mt: 1.5, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{job?.fullSummary ?? job?.shortSummary ?? "Track company context, communication, files, and next steps in one place."}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
|
||||
</Box>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
|
||||
<Tab label="Overview" />
|
||||
<Tab label="Correspondence" />
|
||||
<Tab label="Attachments" />
|
||||
<Tab label="Cover Letter" />
|
||||
<Tab label="Tailored CV" />
|
||||
<Tab label="Follow-up draft" />
|
||||
<Tab label="Candidate fit" />
|
||||
<Tab label="Interview prep" />
|
||||
<Tab label="Readiness" />
|
||||
{isAdmin ? <Tab label="History" /> : null}
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">Date Applied</Typography>
|
||||
<Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Days Since</Typography>
|
||||
<Typography>{job?.daysSince ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Location</Typography>
|
||||
<Typography>{job?.location ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Salary</Typography>
|
||||
<Typography>{job?.salary ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Next Action</Typography>
|
||||
<Typography>{job?.nextAction ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Follow Up</Typography>
|
||||
<Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Deadline</Typography>
|
||||
<Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Tags</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">Attachment Types</Typography>
|
||||
<Typography>{checklist}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Job URL</Typography>
|
||||
<Typography>
|
||||
{job?.jobUrl ? (
|
||||
<a href={job.jobUrl} target="_blank" rel="noreferrer">
|
||||
{job.jobUrl}
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Description (original)</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.description ?? ""}</Typography>
|
||||
</Box>
|
||||
{job?.translatedDescription ? (
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Translated description</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job.translatedDescription}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box><Typography variant="overline">Date Applied</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Days Since</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Location</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Salary</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Next Action</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Follow Up</Typography><Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Deadline</Typography><Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Tags</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">Attachment Types</Typography><Typography>{checklist}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Job URL</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">Summary and skills</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={refreshingAi}
|
||||
onClick={async () => {
|
||||
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
if (!(await confirmAction("Overwrite the current summary and skills with a freshly generated version?", { title: "Refresh AI summary", confirmLabel: "Refresh" }))) return;
|
||||
setRefreshingAi(true);
|
||||
@@ -213,87 +207,173 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
} finally {
|
||||
setRefreshingAi(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{refreshingAi ? "Refreshing..." : "Refresh summary and skills"}
|
||||
</Button>
|
||||
}}>{refreshingAi ? "Refreshing..." : "Refresh summary and skills"}</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.fullSummary ?? job?.shortSummary ?? "No summary yet."}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Notes</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
|
||||
</Box>
|
||||
{rawDescriptionText ? <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Original role text</Typography><Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{rawDescriptionText}</Typography></Box> : null}
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
|
||||
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
||||
|
||||
{tab === 3 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(job?.coverLetterText || "")}>Copy</Button>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">Tailored CV for this role</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="outlined" onClick={async () => {
|
||||
try {
|
||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
||||
setTailoredCvText(me.data?.profileCvText ?? "");
|
||||
toast("Loaded your master CV into the tailored editor.", "success");
|
||||
} catch {
|
||||
toast("Failed to load your master CV.", "error");
|
||||
}
|
||||
}}>Start from master CV</Button>
|
||||
<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`);
|
||||
setApplicationPackage(res.data);
|
||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||
toast("Application package generated.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to generate application package.", "error");
|
||||
} finally {
|
||||
setGeneratingPackage(false);
|
||||
}
|
||||
}}>{generatingPackage ? "Generating..." : "Generate application package"}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => setTailoredCvText("")}>Clear</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(tailoredCvText)}>Copy</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCv} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setSavingTailoredCv(true);
|
||||
try {
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: new Date().toISOString() } : prev);
|
||||
setReadiness(null);
|
||||
setInterviewPrep(null);
|
||||
toast("Tailored CV saved.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to save tailored CV.", "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
}
|
||||
}}>{savingTailoredCv ? "Saving..." : "Save tailored CV"}</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.coverLetterText ?? ""}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Start from your master CV, generate a tailored application package, then edit the resume specifically for this company, role, and interview process.</Typography>
|
||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"}</Typography>
|
||||
</Box>
|
||||
|
||||
{applicationPackage ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title="Cover letter draft" content={applicationPackage.coverLetterDraft ?? "No draft available."} />
|
||||
<DraftCard title="Short application answer" content={applicationPackage.applicationAnswerDraft ?? "No draft available."} />
|
||||
<DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} />
|
||||
<ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 4 && (
|
||||
<Box>
|
||||
{loadingDraft ? (
|
||||
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : followUpDraft ? (
|
||||
{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">Reason</Typography>
|
||||
<Typography>{followUpDraft.reason}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Suggested send date</Typography>
|
||||
<Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography>
|
||||
</Box>
|
||||
<Box><Typography variant="overline">Reason</Typography><Typography>{followUpDraft.reason}</Typography></Box>
|
||||
<Box><Typography variant="overline">Suggested send date</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
|
||||
<TextField label="Recipient" value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." />
|
||||
<TextField label="Subject" value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
|
||||
<TextField label="Draft" multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>Copy draft</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()}
|
||||
onClick={async () => {
|
||||
<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,
|
||||
});
|
||||
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);
|
||||
setReadiness(null);
|
||||
toast("Follow-up sent and logged.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to send follow-up.", "error");
|
||||
} finally {
|
||||
setSendingDraft(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sendingDraft ? "Sending..." : "Send and log email"}
|
||||
</Button>
|
||||
}}>{sendingDraft ? "Sending..." : "Send and log email"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>No draft available.</Typography>
|
||||
)}
|
||||
) : <Typography sx={{ color: "text.secondary" }}>No draft available.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 5 && isAdmin && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{history.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>No history yet.</Typography>
|
||||
) : (
|
||||
history.map((e) => <PaperRow key={e.id} type={e.type} oldValue={e.oldValue} newValue={e.newValue} at={e.at} note={e.note} />)
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box><Typography variant="overline">How you match</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip label={`${candidateFit.matchScore}% match`} 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="Tailored pitch" content={candidateFit.tailoredPitch} />
|
||||
<SectionChips title="Strong matches" items={candidateFit.strengths} color="success" />
|
||||
<SectionChips title="Possible gaps" items={candidateFit.gaps} color="warning" outlined />
|
||||
<TwoColumnSection leftTitle="What to mention" leftItems={candidateFit.mention} rightTitle="What not to overstate" rightItems={candidateFit.avoid} />
|
||||
<TwoColumnSection leftTitle="Improve your CV for this role" leftItems={candidateFit.cvImprovements} rightTitle="Missing keywords to consider" rightItems={candidateFit.missingKeywords} />
|
||||
<TwoColumnSection leftTitle="Interview prep" leftItems={candidateFit.interviewPrep} rightTitle="CV guidance" rightItems={candidateFit.guidance.cv} />
|
||||
<TwoColumnSection leftTitle="Cover letter guidance" leftItems={candidateFit.guidance.coverLetter} rightTitle="Recruiter message guidance" rightItems={candidateFit.guidance.recruiterMessage} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title="Cover letter draft" content={candidateFit.coverLetterDraft ?? "No draft available yet."} />
|
||||
<DraftCard title="Recruiter message draft" content={candidateFit.recruiterMessageDraft ?? "No draft available yet."} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 6 && (
|
||||
<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="Interview prep brief" content={interviewPrep.summary} />
|
||||
<TwoColumnSection leftTitle="Talking points" leftItems={interviewPrep.talkingPoints} rightTitle="Likely questions" rightItems={interviewPrep.likelyQuestions} />
|
||||
<ListCard title="Weak spots to prepare for" items={interviewPrep.weakSpots} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>No interview prep available yet.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 7 && (
|
||||
<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">Application readiness</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip label={`${readiness.score}% ready`} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
||||
<Chip label={readiness.level} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<TwoColumnSection leftTitle="Completed" leftItems={readiness.completed} rightTitle="Still missing" rightItems={readiness.missing} />
|
||||
<ListCard title="Smart reminders" items={readiness.reminders} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>No readiness analysis available yet.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 8 && isAdmin && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No history yet.</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>
|
||||
@@ -301,16 +381,61 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||
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)}>Copy</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" }}>Nothing highlighted yet.</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 }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>Copy</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" }}>Nothing highlighted yet.</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({ title, content }: { title: string; content: string }) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(content)}>Copy</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{content}</Typography>
|
||||
</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}
|
||||
{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() : ""}
|
||||
|
||||
@@ -265,6 +265,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
};
|
||||
|
||||
const generateOverview = (job: JobApplication) => {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
if (job.shortSummary) return job.shortSummary;
|
||||
const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
return src.length > 220 ? `${src.slice(0, 220)}...` : src;
|
||||
@@ -357,6 +358,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{job.needsFollowUp ? <Chip size="small" label="Follow up" title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
|
||||
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label="CV missing" color="warning" variant="outlined" /> : null}
|
||||
{job.tailoredCvText ? <Chip size="small" label="CV ready" color="success" variant="outlined" /> : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Paper,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
@@ -14,15 +8,63 @@ import { useToast } from "../toast";
|
||||
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
missingInterviewNotes: JobApplication[];
|
||||
overdueFollowUp: JobApplication[];
|
||||
other: JobApplication[];
|
||||
};
|
||||
|
||||
function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] };
|
||||
items.forEach((item) => {
|
||||
const reason = (item.followUpReason ?? "").toLowerCase();
|
||||
if (reason.includes("tailored cv")) groups.missingCv.push(item);
|
||||
else if (reason.includes("interview prep") || reason.includes("prep notes")) groups.missingInterviewNotes.push(item);
|
||||
else if (reason.includes("follow-up") || reason.includes("follow up")) groups.overdueFollowUp.push(item);
|
||||
else groups.other.push(item);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{items.map((j) => (
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label="Follow up" /> : null}
|
||||
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>Open</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>Clear</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RemindersView() {
|
||||
const { toast } = useToast();
|
||||
const [items, setItems] = useState<JobApplication[]>([]);
|
||||
const [openJobId, setOpenJobId] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", {
|
||||
params: { upcomingDays: 14 },
|
||||
});
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
|
||||
setItems(res.data);
|
||||
};
|
||||
|
||||
@@ -30,17 +72,12 @@ export default function RemindersView() {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
|
||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||
try {
|
||||
const d =
|
||||
daysFromNow === null
|
||||
? null
|
||||
: new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, {
|
||||
followUpAt: d,
|
||||
});
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
|
||||
toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success");
|
||||
await load();
|
||||
} catch {
|
||||
@@ -50,97 +87,26 @@ export default function RemindersView() {
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Needs Follow-up
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Needs Follow-up</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Based on your rules and upcoming follow-up dates.
|
||||
Grouped by the most useful next action so you can fix gaps faster.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{items.map((j) => (
|
||||
<Paper
|
||||
key={j.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{j.company?.name ?? ""}{" "}
|
||||
<span style={{ fontWeight: 700, opacity: 0.7 }}>�</span>{" "}
|
||||
{j.jobTitle}
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title="Missing tailored CV" items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Missing interview prep" items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Follow-up due" items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Other reminders" items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>Nothing to follow up right now.</Typography> : null}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Tip: focus on tailored CV and interview prep first for the highest-value roles.
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? (
|
||||
<Chip size="small" color="warning" label="Follow up" />
|
||||
) : null}
|
||||
{j.followUpReason ? (
|
||||
<Chip size="small" label={j.followUpReason} variant="outlined" />
|
||||
) : null}
|
||||
{j.followUpAt ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => setOpenJobId(j.id)}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 1)}>
|
||||
Tomorrow
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 3)}>
|
||||
+3d
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 7)}>
|
||||
+7d
|
||||
</Button>
|
||||
<Button size="small" onClick={() => void setFollowUp(j.id, null)}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>
|
||||
Nothing to follow up right now.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={openJobId !== null}
|
||||
jobId={openJobId}
|
||||
onClose={() => setOpenJobId(null)}
|
||||
/>
|
||||
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
lign: "center", py: 3 }}>
|
||||
Nothing to follow up right now.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={openJobId !== null}
|
||||
jobId={openJobId}
|
||||
onClose={() => setOpenJobId(null)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
@@ -14,6 +14,7 @@ type MeResponse = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
profileCvText?: string;
|
||||
roles?: string[];
|
||||
googleLink?: {
|
||||
linked: boolean;
|
||||
@@ -35,8 +36,10 @@ function initialsFrom(values: Array<string | undefined>) {
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
@@ -44,6 +47,7 @@ export default function ProfilePage() {
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [headline, setHeadline] = useState("");
|
||||
const [profileCvText, setProfileCvText] = useState("");
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
@@ -57,6 +61,7 @@ export default function ProfilePage() {
|
||||
setFirstName(r.data?.firstName ?? "");
|
||||
setLastName(r.data?.lastName ?? "");
|
||||
setDisplayName(r.data?.displayName ?? "");
|
||||
setProfileCvText(r.data?.profileCvText ?? "");
|
||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||
} catch {
|
||||
setMe(null);
|
||||
@@ -70,6 +75,7 @@ export default function ProfilePage() {
|
||||
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
||||
const isLocal = me?.provider === "local";
|
||||
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
||||
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -87,6 +93,7 @@ export default function ProfilePage() {
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Chip label={me?.provider === "local" ? "Local account" : me?.provider === "google" ? "Google session" : "External session"} color={me?.provider === "local" ? "primary" : "default"} />
|
||||
<Chip label={me?.googleLink?.linked ? `Google linked${me.googleLink.email ? `: ${me.googleLink.email}` : ""}` : "Google not linked"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={profileCvText.trim() ? `CV ready · ${cvWordCount} words` : "CV missing"} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -115,6 +122,67 @@ export default function ProfilePage() {
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">Master CV</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,text/plain,text/markdown"
|
||||
style={{ display: "none" }}
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
setUploadingCv(true);
|
||||
try {
|
||||
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadProfile();
|
||||
toast("CV text imported.", "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error");
|
||||
} finally {
|
||||
setUploadingCv(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => fileInputRef.current?.click()}>
|
||||
{uploadingCv ? "Importing..." : "Import .txt/.md"}
|
||||
</Button>
|
||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||
Copy CV text
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
|
||||
<TextField
|
||||
label="Profile CV / master resume text"
|
||||
value={profileCvText}
|
||||
onChange={(e) => setProfileCvText(e.target.value)}
|
||||
helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next."
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{cvWordCount} words
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Tip: plain text works best right now.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
|
||||
@@ -125,7 +193,7 @@ export default function ProfilePage() {
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
|
||||
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText });
|
||||
window.localStorage.setItem("profileHeadline", headline.trim());
|
||||
await loadProfile();
|
||||
toast("Profile updated.", "success");
|
||||
|
||||
@@ -48,6 +48,53 @@ export interface JobApplication {
|
||||
followUpReason?: string;
|
||||
}
|
||||
|
||||
export interface CandidateFitChannelGuidance {
|
||||
cv: string[];
|
||||
coverLetter: string[];
|
||||
interview: string[];
|
||||
recruiterMessage: string[];
|
||||
}
|
||||
|
||||
export interface CandidateFit {
|
||||
matchSummary: string;
|
||||
fitLevel: string;
|
||||
matchScore: number;
|
||||
strengths: string[];
|
||||
gaps: string[];
|
||||
mention: string[];
|
||||
avoid: string[];
|
||||
cvImprovements: string[];
|
||||
missingKeywords: string[];
|
||||
interviewPrep: string[];
|
||||
tailoredPitch: string;
|
||||
guidance: CandidateFitChannelGuidance;
|
||||
coverLetterDraft?: string | null;
|
||||
recruiterMessageDraft?: string | null;
|
||||
}
|
||||
|
||||
export interface InterviewPrepResponse {
|
||||
summary: string;
|
||||
talkingPoints: string[];
|
||||
likelyQuestions: string[];
|
||||
weakSpots: string[];
|
||||
}
|
||||
|
||||
export interface ReadinessResponse {
|
||||
score: number;
|
||||
level: string;
|
||||
completed: string[];
|
||||
missing: string[];
|
||||
reminders: string[];
|
||||
}
|
||||
|
||||
export interface ApplicationPackageResponse {
|
||||
tailoredCvText: string;
|
||||
coverLetterDraft?: string | null;
|
||||
applicationAnswerDraft?: string | null;
|
||||
recruiterMessageDraft?: string | null;
|
||||
keyPoints: string[];
|
||||
}
|
||||
|
||||
export interface CorrespondenceMessage {
|
||||
id: number;
|
||||
jobApplicationId: number;
|
||||
|
||||
+69
-71
@@ -11,25 +11,24 @@ app = FastAPI(title="Local Summarizer")
|
||||
MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
|
||||
MAX_INPUT_CHARS = 20000
|
||||
|
||||
# The local summarizer is intentionally simple, but we still validate request sizes
|
||||
# so accidental giant pastes do not cause avoidable latency or memory spikes.
|
||||
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
||||
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME)
|
||||
model.eval()
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
model.to(device)
|
||||
cache = TTLCache(maxsize=1024, ttl=60 * 60) # 1 hour cache
|
||||
cache = TTLCache(maxsize=1024, ttl=60 * 60)
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS)
|
||||
max_length: int = Field(default=160, ge=24, le=256)
|
||||
min_length: int = Field(default=45, ge=8, le=180)
|
||||
top_skills: int = Field(default=8, ge=3, le=12)
|
||||
|
||||
|
||||
def _key(text: str, max_length: int, min_length: int) -> str:
|
||||
def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str:
|
||||
h = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return f"{h}:{max_length}:{min_length}"
|
||||
return f"{h}:{max_length}:{min_length}:{top_skills}"
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -38,52 +37,39 @@ async def health():
|
||||
|
||||
|
||||
_TECH = [
|
||||
"python",
|
||||
"c#",
|
||||
"dotnet",
|
||||
".net",
|
||||
"java",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"react",
|
||||
"node",
|
||||
"sql",
|
||||
"postgres",
|
||||
"postgresql",
|
||||
"mysql",
|
||||
"sqlite",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"terraform",
|
||||
"linux",
|
||||
"git",
|
||||
"ci/cd",
|
||||
"graphql",
|
||||
"rest",
|
||||
"python", "c#", "dotnet", ".net", "java", "javascript", "typescript", "react", "node", "sql",
|
||||
"postgres", "postgresql", "mysql", "sqlite", "mongodb", "redis", "aws", "azure", "gcp",
|
||||
"docker", "kubernetes", "terraform", "linux", "git", "ci/cd", "graphql", "rest",
|
||||
]
|
||||
|
||||
_SOFT = [
|
||||
"communication",
|
||||
"collaboration",
|
||||
"teamwork",
|
||||
"problem solving",
|
||||
"leadership",
|
||||
"mentoring",
|
||||
"ownership",
|
||||
"initiative",
|
||||
"adaptability",
|
||||
"stakeholder management",
|
||||
"detail oriented",
|
||||
"communication", "collaboration", "teamwork", "problem solving", "leadership", "mentoring",
|
||||
"ownership", "initiative", "adaptability", "stakeholder management", "detail oriented",
|
||||
]
|
||||
|
||||
_TECH_PRIORITY = [
|
||||
"python", "c#", ".net", "dotnet", "typescript", "javascript", "react", "node",
|
||||
"sql", "postgresql", "postgres", "mysql", "sqlite", "docker", "kubernetes",
|
||||
"aws", "azure", "gcp", "terraform", "graphql", "rest", "git",
|
||||
]
|
||||
|
||||
|
||||
def _rank_tech_skills(skills):
|
||||
ordered = []
|
||||
seen = set()
|
||||
for preferred in _TECH_PRIORITY:
|
||||
for skill in skills:
|
||||
if skill == preferred and skill not in seen:
|
||||
ordered.append(skill)
|
||||
seen.add(skill)
|
||||
for skill in skills:
|
||||
if skill not in seen:
|
||||
ordered.append(skill)
|
||||
seen.add(skill)
|
||||
return ordered
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
# Good enough for job descriptions pasted from the web.
|
||||
text = re.sub(r"<\s*br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</p\s*>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
@@ -105,6 +91,21 @@ def _extract_bullets(lines, max_items=8):
|
||||
return out
|
||||
|
||||
|
||||
def _top_keywords(text: str, limit=6):
|
||||
words = re.findall(r"[a-zA-Z][a-zA-Z+#./-]{2,}", text.lower())
|
||||
stop = {
|
||||
"with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work",
|
||||
"experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years",
|
||||
}
|
||||
counts = {}
|
||||
for word in words:
|
||||
if word in stop or word in _TECH or word in _SOFT:
|
||||
continue
|
||||
counts[word] = counts.get(word, 0) + 1
|
||||
ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
||||
return [word for word, _ in ordered[:limit]]
|
||||
|
||||
|
||||
def _role_focused_excerpt(text: str) -> dict:
|
||||
cleaned = _strip_html(text)
|
||||
lines = [ln.strip() for ln in cleaned.splitlines()]
|
||||
@@ -117,10 +118,10 @@ def _role_focused_excerpt(text: str) -> dict:
|
||||
|
||||
def match_heading(s: str):
|
||||
sl = s.lower().strip(":-\x7f ")
|
||||
for k, words in headings.items():
|
||||
for w in words:
|
||||
if sl == w or sl.startswith(w + " "):
|
||||
return k
|
||||
for key, words in headings.items():
|
||||
for word in words:
|
||||
if sl == word or sl.startswith(word + " "):
|
||||
return key
|
||||
return None
|
||||
|
||||
section = None
|
||||
@@ -131,11 +132,10 @@ def _role_focused_excerpt(text: str) -> dict:
|
||||
for ln in lines:
|
||||
if not ln:
|
||||
continue
|
||||
h = match_heading(ln)
|
||||
if h:
|
||||
section = h
|
||||
heading = match_heading(ln)
|
||||
if heading:
|
||||
section = heading
|
||||
continue
|
||||
|
||||
if section == "responsibilities":
|
||||
resp_lines.append(ln)
|
||||
elif section == "requirements":
|
||||
@@ -157,7 +157,6 @@ def _role_focused_excerpt(text: str) -> dict:
|
||||
if s in low:
|
||||
soft_found.append(s)
|
||||
|
||||
# Fallback: pick bullet-like lines anywhere if sections are missing.
|
||||
if not responsibilities and not requirements:
|
||||
any_bullets = _extract_bullets(lines, max_items=10)
|
||||
responsibilities = any_bullets[:6]
|
||||
@@ -170,8 +169,6 @@ def _role_focused_excerpt(text: str) -> dict:
|
||||
focused_parts.append("Requirements:\n- " + "\n- ".join(requirements))
|
||||
if nice:
|
||||
focused_parts.append("Nice to have:\n- " + "\n- ".join(nice))
|
||||
|
||||
# Always include a small slice of the original for context.
|
||||
focused_parts.append("Context:\n" + cleaned[:1500])
|
||||
|
||||
return {
|
||||
@@ -182,6 +179,7 @@ def _role_focused_excerpt(text: str) -> dict:
|
||||
"nice": nice,
|
||||
"tech": tech_found,
|
||||
"soft": soft_found,
|
||||
"keywords": _top_keywords(cleaned),
|
||||
}
|
||||
|
||||
|
||||
@@ -206,20 +204,18 @@ async def summarize(req: SummarizeRequest):
|
||||
if req.min_length >= req.max_length:
|
||||
raise HTTPException(status_code=400, detail="min_length must be smaller than max_length.")
|
||||
|
||||
key = _key(req.text, req.max_length, req.min_length)
|
||||
key = _key(req.text, req.max_length, req.min_length, req.top_skills)
|
||||
if key in cache:
|
||||
return {"summary": cache[key], "cached": True}
|
||||
|
||||
info = _role_focused_excerpt(req.text)
|
||||
|
||||
# Summarize the role-focused excerpt instead of the whole job post.
|
||||
summary = _model_summarize(info["focused_input"], req.max_length, req.min_length)
|
||||
|
||||
lines = ["Role summary:", summary]
|
||||
|
||||
if info["requirements"]:
|
||||
lines.append("")
|
||||
lines.append("What they need from you:")
|
||||
lines.append("What the company wants most:")
|
||||
for x in info["requirements"][:7]:
|
||||
lines.append(f"- {x}")
|
||||
|
||||
@@ -241,7 +237,7 @@ async def summarize(req: SummarizeRequest):
|
||||
if t not in uniq:
|
||||
uniq.append(t)
|
||||
lines.append("")
|
||||
lines.append("Top hard skills: " + ", ".join(uniq[:10]))
|
||||
lines.append("Top hard skills: " + ", ".join(uniq[: req.top_skills]))
|
||||
|
||||
if info["soft"]:
|
||||
uniq_soft = []
|
||||
@@ -251,18 +247,20 @@ async def summarize(req: SummarizeRequest):
|
||||
lines.append("")
|
||||
lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8]))
|
||||
|
||||
out = "\n".join(lines).strip()
|
||||
cache[key] = out
|
||||
return {"summary": out, "cached": False}
|
||||
skills: " + ", ".join(uniq[:14]))
|
||||
|
||||
if info["soft"]:
|
||||
uniq_soft = []
|
||||
for s in info["soft"]:
|
||||
if s not in uniq_soft:
|
||||
uniq_soft.append(s)
|
||||
if info["keywords"]:
|
||||
lines.append("")
|
||||
lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8]))
|
||||
lines.append("Key themes: " + ", ".join(info["keywords"][:6]))
|
||||
|
||||
lines.append("")
|
||||
lines.append("Interview focus:")
|
||||
if info["requirements"]:
|
||||
for x in info["requirements"][:3]:
|
||||
lines.append(f"- Prepare examples that demonstrate: {x}")
|
||||
elif info["tech"]:
|
||||
for x in _rank_tech_skills(info["tech"])[:3]:
|
||||
lines.append(f"- Be ready to explain your hands-on experience with {x}")
|
||||
else:
|
||||
lines.append("- Prepare examples showing relevant impact, collaboration, and delivery.")
|
||||
|
||||
out = "\n".join(lines).strip()
|
||||
cache[key] = out
|
||||
|
||||
Reference in New Issue
Block a user