Add reminder emails and AI CV improvement tools
This commit is contained in:
@@ -91,6 +91,34 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
return Ok(new { imported = true, characters = text.Length });
|
return Ok(new { imported = true, characters = text.Length });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("improve")]
|
||||||
|
public async Task<IActionResult> Improve()
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before improving it.");
|
||||||
|
|
||||||
|
var improved = await _aiService.SummarizeSectionAsync(
|
||||||
|
"Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.",
|
||||||
|
user.ProfileCvText,
|
||||||
|
1800,
|
||||||
|
500);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(improved))
|
||||||
|
{
|
||||||
|
return BadRequest("The AI service could not improve your CV text right now.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ProfileCvText = improved.Trim();
|
||||||
|
var result = await _users.UpdateAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText });
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
|
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
|
||||||
{
|
{
|
||||||
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ builder.Services.AddDataProtection()
|
|||||||
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
|
||||||
.SetApplicationName("JobTracker");
|
.SetApplicationName("JobTracker");
|
||||||
builder.Services.AddHostedService<RulesHostedService>();
|
builder.Services.AddHostedService<RulesHostedService>();
|
||||||
|
builder.Services.AddHostedService<FollowUpReminderHostedService>();
|
||||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||||
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||||
@@ -666,6 +667,7 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
|
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", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
|
||||||
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
|
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
|
||||||
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
|
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
|
||||||
|
|
||||||
// Ensure ownership columns exist even on non-legacy DBs.
|
// Ensure ownership columns exist even on non-legacy DBs.
|
||||||
@@ -727,6 +729,7 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
|
|
||||||
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public sealed class FollowUpReminderHostedService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly ILogger<FollowUpReminderHostedService> _logger;
|
||||||
|
|
||||||
|
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_cfg = cfg;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SendDueReminderEmailsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Follow-up reminder email pass failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendDueReminderEmailsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var enabled = _cfg.GetValue("Email:FollowUpReminders:Enabled", false);
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
var baseUrl = (_cfg["App:BaseUrl"] ?? _cfg["Frontend:BaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl)) return;
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var email = scope.ServiceProvider.GetRequiredService<IAppEmailSender>();
|
||||||
|
|
||||||
|
var settings = await RulesEngine.GetSettings(db, cancellationToken);
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var lookAheadDays = Math.Clamp(_cfg.GetValue("Email:FollowUpReminders:UpcomingDays", 2), 1, 14);
|
||||||
|
var upcomingTo = now.AddDays(lookAheadDays);
|
||||||
|
|
||||||
|
var lastMsg = await db.Correspondences
|
||||||
|
.AsNoTracking()
|
||||||
|
.GroupBy(c => c.JobApplicationId)
|
||||||
|
.Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) })
|
||||||
|
.ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken);
|
||||||
|
|
||||||
|
var jobs = await db.JobApplications
|
||||||
|
.Include(j => j.Company)
|
||||||
|
.Where(j => !j.IsDeleted && j.OwnerUserId != null)
|
||||||
|
.Where(j =>
|
||||||
|
(j.FollowUpAt != null && j.FollowUpAt <= upcomingTo) ||
|
||||||
|
j.Status == "Applied" ||
|
||||||
|
j.Status == "Waiting" ||
|
||||||
|
j.Status == "Offer" ||
|
||||||
|
(j.Status == "Rejected" && j.FeedbackRequestedAt != null))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
if (job.OwnerUserId is null) continue;
|
||||||
|
if (job.LastReminderEmailSentAt?.Date == now.Date) continue;
|
||||||
|
|
||||||
|
lastMsg.TryGetValue(job.Id, out var lm);
|
||||||
|
var decision = RulesEngine.Evaluate(settings, job, now, lm);
|
||||||
|
var upcoming = job.FollowUpAt is not null && job.FollowUpAt.Value <= upcomingTo;
|
||||||
|
if (!decision.NeedsFollowUp && !upcoming) continue;
|
||||||
|
|
||||||
|
var owner = await users.FindByIdAsync(job.OwnerUserId);
|
||||||
|
if (owner is null || !owner.EmailConfirmed || string.IsNullOrWhiteSpace(owner.Email)) continue;
|
||||||
|
|
||||||
|
var reason = BuildReminderReason(job, decision.Reason, upcoming);
|
||||||
|
var detailsUrl = $"{baseUrl}/jobs?open={job.Id}&tab=4";
|
||||||
|
var companyName = job.Company?.Name ?? "Unknown company";
|
||||||
|
var appliedOn = job.DateApplied.ToString("MMMM d, yyyy");
|
||||||
|
var subject = $"Follow up reminder: {job.JobTitle} at {companyName}";
|
||||||
|
var body = string.Join("\n\n", new[]
|
||||||
|
{
|
||||||
|
$"Hi {(owner.UserName ?? owner.Email ?? "there")},",
|
||||||
|
$"This is your Jobbjakt reminder to follow up on the {job.JobTitle} role at {companyName}.",
|
||||||
|
$"Applied on: {appliedOn}\nCurrent status: {job.Status}\nWhy now: {reason}",
|
||||||
|
$"Open the follow-up generator for this job:\n{detailsUrl}",
|
||||||
|
"Tip: review the generated follow-up draft, candidate-fit notes, and recruiter message before sending.",
|
||||||
|
"— Jobbjakt"
|
||||||
|
});
|
||||||
|
|
||||||
|
await email.SendAsync(owner.Email!, subject, body, cancellationToken);
|
||||||
|
job.LastReminderEmailSentAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildReminderReason(JobApplication job, string? engineReason, bool upcoming)
|
||||||
|
{
|
||||||
|
if (upcoming && job.FollowUpAt is not null)
|
||||||
|
{
|
||||||
|
return $"a follow-up date is scheduled for {job.FollowUpAt.Value:MMMM d, yyyy}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(engineReason)) return engineReason.Trim();
|
||||||
|
|
||||||
|
return job.Status switch
|
||||||
|
{
|
||||||
|
"Applied" => "you applied and have not logged a response yet",
|
||||||
|
"Waiting" => "you are waiting on next steps",
|
||||||
|
"Offer" => "the process appears to be stalled after progress",
|
||||||
|
_ => "the application may need attention"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ public class JobApplication
|
|||||||
public string? ShortSummary { get; set; }
|
public string? ShortSummary { get; set; }
|
||||||
public string? TailoredCvText { get; set; }
|
public string? TailoredCvText { get; set; }
|
||||||
public DateTime? TailoredCvUpdatedAt { get; set; }
|
public DateTime? TailoredCvUpdatedAt { get; set; }
|
||||||
|
public DateTime? LastReminderEmailSentAt { get; set; }
|
||||||
|
|
||||||
public List<Correspondence> Messages { get; set; } = new();
|
public List<Correspondence> Messages { get; set; } = new();
|
||||||
public List<Attachment> Attachments { get; set; } = new();
|
public List<Attachment> Attachments { get; set; } = new();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Last updated: 2026-03-23
|
|||||||
- [x] Add OCR support for supported image CV uploads (`png`, `jpg`, `jpeg`, `webp`)
|
- [x] Add OCR support for supported image CV uploads (`png`, `jpg`, `jpeg`, `webp`)
|
||||||
- [x] Add AI service latency/OCR telemetry to the system page
|
- [x] Add AI service latency/OCR telemetry to the system page
|
||||||
- [x] Add frontend test coverage for AI service status rendering
|
- [x] Add frontend test coverage for AI service status rendering
|
||||||
|
- [x] Add CV text improvement flow powered by the AI service
|
||||||
- [ ] Extend AI extraction to job attachment ingestion
|
- [ ] Extend AI extraction to job attachment ingestion
|
||||||
- [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*`
|
- [ ] Consider full internal service/class rename from `Summarizer*` to `AiService*`
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ Last updated: 2026-03-23
|
|||||||
- [x] Move SMTP test from user page to admin/system page
|
- [x] Move SMTP test from user page to admin/system page
|
||||||
- [x] Move language selector into Settings
|
- [x] Move language selector into Settings
|
||||||
- [x] Make language selection apply globally
|
- [x] Make language selection apply globally
|
||||||
|
- [x] Add scheduled follow-up reminder emails with direct links to the generator
|
||||||
- [ ] Audit app-wide preference for username over email
|
- [ ] Audit app-wide preference for username over email
|
||||||
- [ ] Verify auto-fill of email and full name from profile where relevant
|
- [ ] Verify auto-fill of email and full name from profile where relevant
|
||||||
|
|
||||||
@@ -77,5 +79,6 @@ Last updated: 2026-03-23
|
|||||||
## UX / Consistency
|
## UX / Consistency
|
||||||
- [x] Simplify create-job workflow
|
- [x] Simplify create-job workflow
|
||||||
- [x] Reduce duplicated UI/data across multiple pages
|
- [x] Reduce duplicated UI/data across multiple pages
|
||||||
|
- [x] Add direct follow-up deep links for specific jobs
|
||||||
- [ ] Perform final UX clarity pass across major screens
|
- [ ] Perform final UX clarity pass across major screens
|
||||||
- [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages
|
- [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface Props {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
jobId: number | null;
|
jobId: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
initialTab?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
|
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
|
||||||
@@ -69,7 +70,7 @@ function copyLines(items: string[]) {
|
|||||||
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction } = useDialogActions();
|
||||||
@@ -100,7 +101,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !jobId) return;
|
if (!open || !jobId) return;
|
||||||
setTab(0);
|
setTab(Math.max(0, Math.min(8, initialTab)));
|
||||||
setFollowUpDraft(null);
|
setFollowUpDraft(null);
|
||||||
setCandidateFit(null);
|
setCandidateFit(null);
|
||||||
setInterviewPrep(null);
|
setInterviewPrep(null);
|
||||||
@@ -113,7 +114,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
});
|
});
|
||||||
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
|
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([]));
|
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||||
}, [open, jobId]);
|
}, [open, jobId, initialTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !jobId || tab !== 4 || followUpDraft) return;
|
if (!open || !jobId || tab !== 4 || followUpDraft) return;
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
const { companies } = useCompanies();
|
const { companies } = useCompanies();
|
||||||
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
|
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
|
||||||
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
|
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
|
||||||
|
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
|
||||||
const [editJobId, setEditJobId] = useState<number | null>(null);
|
const [editJobId, setEditJobId] = useState<number | null>(null);
|
||||||
const [reloadToken, setReloadToken] = useState(0);
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
|
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
|
||||||
@@ -179,11 +180,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const paramsSearch = new URLSearchParams(location.search);
|
const paramsSearch = new URLSearchParams(location.search);
|
||||||
const openId = Number(paramsSearch.get("open") || 0);
|
const openId = Number(paramsSearch.get("open") || 0);
|
||||||
|
const tabIndex = Number(paramsSearch.get("tab") || 0);
|
||||||
if (!openId || jobs.length === 0) return;
|
if (!openId || jobs.length === 0) return;
|
||||||
const job = jobs.find((j) => j.id === openId);
|
const job = jobs.find((j) => j.id === openId);
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
setDetailsJobId(openId);
|
setDetailsJobId(openId);
|
||||||
|
setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(8, tabIndex)) : 0);
|
||||||
paramsSearch.delete("open");
|
paramsSearch.delete("open");
|
||||||
|
paramsSearch.delete("tab");
|
||||||
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
|
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
|
||||||
}, [jobs, location.pathname, location.search, navigate]);
|
}, [jobs, location.pathname, location.search, navigate]);
|
||||||
|
|
||||||
@@ -409,7 +413,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
|
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} onClose={() => setDetailsJobId(null)} />
|
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); }} />
|
||||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
|
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
|
||||||
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
|
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
|
||||||
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
|
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ export const translations = {
|
|||||||
profileMasterCv: "Master CV",
|
profileMasterCv: "Master CV",
|
||||||
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.",
|
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.",
|
||||||
profileUploadCv: "Upload CV",
|
profileUploadCv: "Upload CV",
|
||||||
|
profileCvImprove: "Improve CV text",
|
||||||
|
profileCvImproving: "Improving CV...",
|
||||||
|
profileCvImproved: "CV text improved.",
|
||||||
|
profileCvImproveFailed: "Failed to improve CV text.",
|
||||||
profileUploading: "Uploading...",
|
profileUploading: "Uploading...",
|
||||||
profileCopyCvText: "Copy CV text",
|
profileCopyCvText: "Copy CV text",
|
||||||
profileCvUploaded: "CV uploaded and processed.",
|
profileCvUploaded: "CV uploaded and processed.",
|
||||||
@@ -896,6 +900,10 @@ export const translations = {
|
|||||||
profileMasterCv: "Hoved-CV",
|
profileMasterCv: "Hoved-CV",
|
||||||
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.",
|
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.",
|
||||||
profileUploadCv: "Last opp CV",
|
profileUploadCv: "Last opp CV",
|
||||||
|
profileCvImprove: "Forbedre CV-tekst",
|
||||||
|
profileCvImproving: "Forbedrer CV...",
|
||||||
|
profileCvImproved: "CV-tekst forbedret.",
|
||||||
|
profileCvImproveFailed: "Kunne ikke forbedre CV-tekst.",
|
||||||
profileUploading: "Laster opp...",
|
profileUploading: "Laster opp...",
|
||||||
profileCopyCvText: "Kopier CV-tekst",
|
profileCopyCvText: "Kopier CV-tekst",
|
||||||
profileCvUploaded: "CV lastet opp og behandlet.",
|
profileCvUploaded: "CV lastet opp og behandlet.",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function ProfilePage() {
|
|||||||
const [me, setMe] = useState<MeResponse | null>(null);
|
const [me, setMe] = useState<MeResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploadingCv, setUploadingCv] = useState(false);
|
const [uploadingCv, setUploadingCv] = useState(false);
|
||||||
|
const [improvingCv, setImprovingCv] = useState(false);
|
||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
const [cropOpen, setCropOpen] = useState(false);
|
const [cropOpen, setCropOpen] = useState(false);
|
||||||
@@ -247,9 +248,28 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => cvInputRef.current?.click()}>
|
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv} onClick={() => cvInputRef.current?.click()}>
|
||||||
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv}
|
||||||
|
onClick={async () => {
|
||||||
|
setImprovingCv(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ text?: string }>("/profile-cv/improve");
|
||||||
|
if (res.data?.text) setProfileCvText(res.data.text);
|
||||||
|
await loadProfile();
|
||||||
|
toast(t("profileCvImproved"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
||||||
|
} finally {
|
||||||
|
setImprovingCv(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
|
||||||
|
</Button>
|
||||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||||
{t("profileCopyCvText")}
|
{t("profileCopyCvText")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user