Track cleanup progress and polish profile/system flows
This commit is contained in:
@@ -133,7 +133,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return Unauthorized("This Google account is not linked to a Job Tracker user yet.");
|
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
|
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
|
||||||
@@ -237,7 +237,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
.FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken);
|
.FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken);
|
||||||
if (conflict is not null)
|
if (conflict is not null)
|
||||||
{
|
{
|
||||||
return Conflict("That Google account is already linked to another Job Tracker user.");
|
return Conflict("That Google account is already linked to another Jobbjakt user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
user.GoogleSubject = google.Subject;
|
user.GoogleSubject = google.Subject;
|
||||||
@@ -393,7 +393,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
await _email.SendAsync(
|
await _email.SendAsync(
|
||||||
user.Email,
|
user.Email,
|
||||||
"Password reset",
|
"Password reset",
|
||||||
$"You requested a password reset for Job Tracker.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.",
|
$"You requested a password reset for Jobbjakt.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.",
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using JobTrackerApi.Services;
|
|||||||
using JobTrackerApi.Services.JobImport;
|
using JobTrackerApi.Services.JobImport;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers
|
namespace JobTrackerApi.Controllers
|
||||||
{
|
{
|
||||||
@@ -16,17 +17,44 @@ namespace JobTrackerApi.Controllers
|
|||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly ISummarizerService _summarizer;
|
private readonly ISummarizerService _summarizer;
|
||||||
private readonly IAppEmailSender _email;
|
private readonly IAppEmailSender _email;
|
||||||
|
private readonly UserManager<ApplicationUser> _users;
|
||||||
|
|
||||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email)
|
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
_email = email;
|
_email = email;
|
||||||
|
_users = users;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? CurrentUserId =>
|
private string? CurrentUserId =>
|
||||||
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
|
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
|
||||||
|
|
||||||
|
private async Task<ApplicationUser?> GetCurrentUserAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = CurrentUserId;
|
||||||
|
if (string.IsNullOrWhiteSpace(userId)) return null;
|
||||||
|
return await _users.FindByIdAsync(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPreferredDisplayName(ApplicationUser? user)
|
||||||
|
{
|
||||||
|
if (user is null) return "Your Name";
|
||||||
|
if (!string.IsNullOrWhiteSpace(user.DisplayName)) return user.DisplayName.Trim();
|
||||||
|
var fullName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
if (!string.IsNullOrWhiteSpace(fullName)) return fullName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(user.UserName)) return user.UserName.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(user.Email)) return user.Email.Trim();
|
||||||
|
return "Your Name";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildGreeting(JobApplication job)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) return $"Hi {job.Company.RecruiterName.Trim()},";
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) return $"Hi {job.Company.Name.Trim()} team,";
|
||||||
|
return "Hi there,";
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> SplitTags(string? s)
|
private static IEnumerable<string> SplitTags(string? s)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(s)) yield break;
|
if (string.IsNullOrWhiteSpace(s)) yield break;
|
||||||
@@ -1747,18 +1775,20 @@ Candidate master CV:
|
|||||||
? (job.FollowUpAt is not null && job.FollowUpAt.Value.Date <= DateTime.Today ? "Scheduled follow-up is due." : "No recent response has been logged.")
|
? (job.FollowUpAt is not null && job.FollowUpAt.Value.Date <= DateTime.Today ? "Scheduled follow-up is due." : "No recent response has been logged.")
|
||||||
: job.NextAction!;
|
: job.NextAction!;
|
||||||
|
|
||||||
|
var currentUser = await GetCurrentUserAsync(cancellationToken);
|
||||||
|
var signerName = GetPreferredDisplayName(currentUser);
|
||||||
|
var greeting = BuildGreeting(job);
|
||||||
var subject = $"Following up on {job.JobTitle} application";
|
var subject = $"Following up on {job.JobTitle} application";
|
||||||
var companyName = job.Company?.Name ?? "your team";
|
|
||||||
var reference = lastMessage?.Subject ?? job.JobTitle;
|
var reference = lastMessage?.Subject ?? job.JobTitle;
|
||||||
var summary = job.ShortSummary;
|
var summary = job.ShortSummary;
|
||||||
var body = string.Join("\n\n", new[]
|
var body = string.Join("\n\n", new[]
|
||||||
{
|
{
|
||||||
$"Hi {companyName},",
|
greeting,
|
||||||
$"I wanted to follow up on my application for the {job.JobTitle} role. I'm still very interested in the opportunity and would love to hear if there are any updates on next steps.",
|
$"I wanted to follow up on my application for the {job.JobTitle} role. I'm still very interested in the opportunity and would love to hear if there are any updates on next steps.",
|
||||||
!string.IsNullOrWhiteSpace(summary) ? $"Quick reminder of fit: {summary}" : null,
|
!string.IsNullOrWhiteSpace(summary) ? $"Quick reminder of fit: {summary}" : null,
|
||||||
$"Context: {reason}",
|
$"Context: {reason}",
|
||||||
$"If helpful, I can also provide any additional information related to {reference}.",
|
$"If helpful, I can also provide any additional information related to {reference}.",
|
||||||
"Thanks for your time,\n[Your name]"
|
$"Thanks for your time,\n{signerName}"
|
||||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
|
||||||
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
|
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
".docx",
|
".docx",
|
||||||
};
|
};
|
||||||
|
|
||||||
private const long MaxFileSizeBytes = 512 * 1024;
|
private const long MaxFileSizeBytes = 5 * 1024 * 1024;
|
||||||
|
|
||||||
private readonly UserManager<ApplicationUser> _users;
|
private readonly UserManager<ApplicationUser> _users;
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
var user = await _users.GetUserAsync(User);
|
var user = await _users.GetUserAsync(User);
|
||||||
if (user is null) return Unauthorized();
|
if (user is null) return Unauthorized();
|
||||||
if (file is null || file.Length == 0) return BadRequest("Select a CV file to upload.");
|
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.");
|
if (file.Length > MaxFileSizeBytes) return BadRequest("CV import file is too large. Keep it under 5 MB.");
|
||||||
|
|
||||||
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||||
if (!AllowedExtensions.Contains(extension))
|
if (!AllowedExtensions.Contains(extension))
|
||||||
@@ -78,7 +78,10 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
var raw = Encoding.UTF8.GetString(bytes);
|
var raw = Encoding.UTF8.GetString(bytes);
|
||||||
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
||||||
.Select(match => match.Groups[1].Value)
|
.Select(match => match.Groups[1].Value)
|
||||||
|
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
|
||||||
|
.SelectMany(match => Regex.Matches(match.Groups[1].Value, @"\((.*?)\)", RegexOptions.Singleline).Select(x => x.Groups[1].Value)))
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => Regex.Unescape(value))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var joined = textMatches.Count > 0 ? string.Join(" ", textMatches) : raw;
|
var joined = textMatches.Count > 0 ? string.Join(" ", textMatches) : raw;
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ public sealed class UsersController : ControllerBase
|
|||||||
await _email.SendAsync(
|
await _email.SendAsync(
|
||||||
u.Email,
|
u.Email,
|
||||||
"Password reset",
|
"Password reset",
|
||||||
$"An admin initiated a password reset for your Job Tracker account.\n\nReset link:\n{link}\n",
|
$"An admin initiated a password reset for your Jobbjakt account.\n\nReset link:\n{link}\n",
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,9 +171,9 @@ public sealed class UsersController : ControllerBase
|
|||||||
var toEmail = (request?.ToEmail ?? currentUser?.Email ?? "").Trim();
|
var toEmail = (request?.ToEmail ?? currentUser?.Email ?? "").Trim();
|
||||||
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
|
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
|
||||||
|
|
||||||
var subject = string.IsNullOrWhiteSpace(request?.Subject) ? "Job Tracker test email" : request!.Subject!.Trim();
|
var subject = string.IsNullOrWhiteSpace(request?.Subject) ? "Jobbjakt test email" : request!.Subject!.Trim();
|
||||||
var message = string.IsNullOrWhiteSpace(request?.Message)
|
var message = string.IsNullOrWhiteSpace(request?.Message)
|
||||||
? "This is a test email from the Job Tracker admin panel.\n\nIf you received this, the SMTP configuration is working."
|
? "This is a test email from the Jobbjakt admin panel.\n\nIf you received this, the SMTP configuration is working."
|
||||||
: request!.Message!.Trim();
|
: request!.Message!.Trim();
|
||||||
|
|
||||||
await _email.SendAsync(
|
await _email.SendAsync(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class SmtpEmailSender : IAppEmailSender
|
|||||||
var user = (_cfg["Email:SmtpUser"] ?? "").Trim();
|
var user = (_cfg["Email:SmtpUser"] ?? "").Trim();
|
||||||
var pass = (_cfg["Email:SmtpPassword"] ?? "").Trim();
|
var pass = (_cfg["Email:SmtpPassword"] ?? "").Trim();
|
||||||
var from = (_cfg["Email:From"] ?? user).Trim();
|
var from = (_cfg["Email:From"] ?? user).Trim();
|
||||||
var fromName = (_cfg["Email:FromName"] ?? "Job Tracker").Trim();
|
var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
|
||||||
|
|
||||||
var port = _cfg.GetValue("Email:SmtpPort", 587);
|
var port = _cfg.GetValue("Email:SmtpPort", 587);
|
||||||
if (port <= 0) port = 587;
|
if (port <= 0) port = 587;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Jobbjakt cleanup tracker
|
||||||
|
|
||||||
|
Last updated: 2026-03-23
|
||||||
|
|
||||||
|
## Build / UI Issues
|
||||||
|
- [x] Fix visible build error text appearing on page load/footer
|
||||||
|
- [x] Resolve naming inconsistency: `jobtrack` → `Jobbjakt`
|
||||||
|
- [ ] Audit placeholder vertical alignment in all text fields
|
||||||
|
- [ ] Complete final UI overhaul / visual consistency pass
|
||||||
|
- [x] Add frontend 404 page
|
||||||
|
- [x] Add frontend route error page
|
||||||
|
|
||||||
|
## Job Creation / Company Features
|
||||||
|
- [x] Remove “Next Action” from create job form
|
||||||
|
- [x] Remove “Follow-up Date” from create job form
|
||||||
|
- [x] Replace “Save and add another” checkbox with action buttons
|
||||||
|
- [x] Add modal Close button at bottom
|
||||||
|
- [x] Add modal top-right close button
|
||||||
|
- [x] Remove file-type checkboxes
|
||||||
|
- [x] Add categorized file uploads for CV / portfolio / other files
|
||||||
|
- [x] Prefer document formats while allowing text/image uploads
|
||||||
|
- [x] Replace inline cover text field with upload-based flow
|
||||||
|
- [ ] Verify translated text area is shown only when content is not English
|
||||||
|
- [x] Improve create-company error visibility in frontend
|
||||||
|
- [ ] Verify company creation works in production after deploy
|
||||||
|
- [x] Add backend production schema guard for company/job ownership columns
|
||||||
|
|
||||||
|
## Profile Page
|
||||||
|
- [x] Remove duplicate “Google account not linked” messages
|
||||||
|
- [x] Add profile image upload
|
||||||
|
- [x] Add crop dialog for profile image
|
||||||
|
- [x] Add zoom in/out support for image cropping
|
||||||
|
- [x] Use square cropped avatar output
|
||||||
|
- [x] Add CV upload support
|
||||||
|
- [ ] Verify/complete OCR/text extraction for uploaded CV PDFs
|
||||||
|
|
||||||
|
## Settings & System
|
||||||
|
- [x] Restore missing follow-up days settings
|
||||||
|
- [x] Move SMTP test from user page to admin/system page
|
||||||
|
- [x] Move language selector into Settings
|
||||||
|
- [x] Make language selection apply globally
|
||||||
|
- [ ] Audit app-wide preference for username over email
|
||||||
|
- [ ] Verify auto-fill of email and full name from profile where relevant
|
||||||
|
|
||||||
|
## Dashboard / System Pages
|
||||||
|
- [x] Reduce duplicate system data on dashboard
|
||||||
|
- [x] Consolidate system-related information into system/admin area
|
||||||
|
- [ ] Audit summarizer/system state consistency across dashboard and system page
|
||||||
|
- [x] Verify there is no separate pipeline page left outside the system/company context
|
||||||
|
- [ ] Remove remaining redundant dashboard/system duplication
|
||||||
|
|
||||||
|
## Translations / Localization
|
||||||
|
- [x] Expand translation infrastructure with interpolation support
|
||||||
|
- [x] Move major app shell/settings/profile/admin/create-job views onto translation system
|
||||||
|
- [x] Translate major table/kanban/edit flows further
|
||||||
|
- [ ] Finish remaining translation coverage in lingering dialogs/components
|
||||||
|
- [ ] Review English/Norwegian phrasing consistency across updated UI
|
||||||
|
|
||||||
|
## Production / Deployment
|
||||||
|
- [x] Audit production API base URL / proxy setup
|
||||||
|
- [x] Document recommendation to leave `REACT_APP_API_BASE_URL` empty when using bundled proxy
|
||||||
|
- [x] Replace committed dev secret-like values with placeholders in dev config
|
||||||
|
- [ ] Rotate any real secrets that may previously have been committed/exposed
|
||||||
|
- [ ] Verify production environment variables match documented setup
|
||||||
|
|
||||||
|
## UX / Consistency
|
||||||
|
- [x] Simplify create-job workflow
|
||||||
|
- [x] Reduce duplicated UI/data across multiple pages
|
||||||
|
- [ ] Perform final UX clarity pass across major screens
|
||||||
|
- [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages
|
||||||
@@ -37,7 +37,7 @@ export default function AuthStatusCard() {
|
|||||||
.catch(() => setMe(null));
|
.catch(() => setMe(null));
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const label = useMemo(() => me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
|
const label = useMemo(() => me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.userName || me?.email, [me]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
|||||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||||
import { IconButton } from "@mui/material";
|
import { IconButton } from "@mui/material";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
@@ -126,8 +126,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
});
|
});
|
||||||
setGmailMessages(res.data);
|
setGmailMessages(res.data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error?.response?.data && typeof error.response.data === "string" ? error.response.data : "Failed to load Gmail messages.";
|
toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error");
|
||||||
toast(message, "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setGmailMessagesLoading(false);
|
setGmailMessagesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -206,8 +205,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
await api.post("/correspondence", { jobApplicationId: jobId, from, content: text });
|
await api.post("/correspondence", { jobApplicationId: jobId, from, content: text });
|
||||||
setText("");
|
setText("");
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast("Failed to add message.", "error");
|
toast(getApiErrorMessage(error, "Failed to add message."), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,8 +230,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
setRawEmail("");
|
setRawEmail("");
|
||||||
await load();
|
await load();
|
||||||
toast("Email logged.", "success");
|
toast("Email logged.", "success");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast("Failed to import email.", "error");
|
toast(getApiErrorMessage(error, "Failed to import email."), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,8 +240,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
||||||
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||||
if (!popup) toast("Your browser blocked the Gmail popup.", "error");
|
if (!popup) toast("Your browser blocked the Gmail popup.", "error");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast("Failed to start Gmail connection.", "error");
|
toast(getApiErrorMessage(error, "Failed to start Gmail connection."), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,8 +251,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
setGmailStatus({ connected: false });
|
setGmailStatus({ connected: false });
|
||||||
setGmailMessages([]);
|
setGmailMessages([]);
|
||||||
toast("Gmail disconnected.", "success");
|
toast("Gmail disconnected.", "success");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast("Failed to disconnect Gmail.", "error");
|
toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,8 +262,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
await api.delete(`/correspondence/${messageId}`);
|
await api.delete(`/correspondence/${messageId}`);
|
||||||
await load();
|
await load();
|
||||||
toast("Message removed.", "success");
|
toast("Message removed.", "success");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast("Failed to remove message.", "error");
|
toast(getApiErrorMessage(error, "Failed to remove message."), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,8 +274,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
await load();
|
await load();
|
||||||
toast("Email imported from Gmail.", "success");
|
toast("Email imported from Gmail.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error?.response?.data && typeof error.response.data === "string" ? error.response.data : "Failed to import Gmail message.";
|
toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error");
|
||||||
toast(message, "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setImportingMessageId(null);
|
setImportingMessageId(null);
|
||||||
}
|
}
|
||||||
@@ -289,8 +287,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
await load();
|
await load();
|
||||||
toast(`Imported ${res.data.imported} messages${res.data.skipped ? `, skipped ${res.data.skipped} duplicates` : ""}.`, "success");
|
toast(`Imported ${res.data.imported} messages${res.data.skipped ? `, skipped ${res.data.skipped} duplicates` : ""}.`, "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error?.response?.data && typeof error.response.data === "string" ? error.response.data : "Failed to import Gmail thread.";
|
toast(getApiErrorMessage(error, "Failed to import Gmail thread."), "error");
|
||||||
toast(message, "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setImportingThreadId(null);
|
setImportingThreadId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ declare global {
|
|||||||
type MeResponse = {
|
type MeResponse = {
|
||||||
provider?: "local" | "google" | "external";
|
provider?: "local" | "google" | "external";
|
||||||
email?: string;
|
email?: string;
|
||||||
|
userName?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
@@ -153,7 +154,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
|||||||
};
|
};
|
||||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
|
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
|
||||||
|
|
||||||
const signedInName = me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
const signedInName = me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.userName || me?.email || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
@@ -216,7 +216,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setJob(res.data);
|
setJob(res.data);
|
||||||
toast("Summary and skills refreshed.", "success");
|
toast("Summary and skills refreshed.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to refresh summary and skills.", "error");
|
toast(getApiErrorMessage(error, "Failed to refresh summary and skills."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshingAi(false);
|
setRefreshingAi(false);
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||||
toast("Application package generated.", "success");
|
toast("Application package generated.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to generate application package.", "error");
|
toast(getApiErrorMessage(error, "Failed to generate application package."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingPackage(false);
|
setGeneratingPackage(false);
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setInterviewPrep(null);
|
setInterviewPrep(null);
|
||||||
toast("Tailored CV saved.", "success");
|
toast("Tailored CV saved.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to save tailored CV.", "error");
|
toast(getApiErrorMessage(error, "Failed to save tailored CV."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingTailoredCv(false);
|
setSavingTailoredCv(false);
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setReadiness(null);
|
setReadiness(null);
|
||||||
toast("Cover letter saved to this job.", "success");
|
toast("Cover letter saved to this job.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to save cover letter.", "error");
|
toast(getApiErrorMessage(error, "Failed to save cover letter."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingApplicationDrafts(false);
|
setSavingApplicationDrafts(false);
|
||||||
}
|
}
|
||||||
@@ -330,7 +330,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setReadiness(null);
|
setReadiness(null);
|
||||||
toast("Application answer saved to notes.", "success");
|
toast("Application answer saved to notes.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to save application answer.", "error");
|
toast(getApiErrorMessage(error, "Failed to save application answer."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingApplicationDrafts(false);
|
setSavingApplicationDrafts(false);
|
||||||
}
|
}
|
||||||
@@ -343,7 +343,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev);
|
setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev);
|
||||||
toast("Recruiter message saved to this job.", "success");
|
toast("Recruiter message saved to this job.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to save recruiter message.", "error");
|
toast(getApiErrorMessage(error, "Failed to save recruiter message."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingApplicationDrafts(false);
|
setSavingApplicationDrafts(false);
|
||||||
}
|
}
|
||||||
@@ -374,7 +374,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
setReadiness(null);
|
setReadiness(null);
|
||||||
toast("Follow-up sent and logged.", "success");
|
toast("Follow-up sent and logged.", "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.response?.data || "Failed to send follow-up.", "error");
|
toast(getApiErrorMessage(error, "Failed to send follow-up."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSendingDraft(false);
|
setSendingDraft(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export const translations = {
|
|||||||
profileHeadline: "Profile headline",
|
profileHeadline: "Profile headline",
|
||||||
profileHeadlineHelp: "Stored only in this browser to personalize your workspace.",
|
profileHeadlineHelp: "Stored only in this browser to personalize your workspace.",
|
||||||
profileMasterCv: "Master CV",
|
profileMasterCv: "Master CV",
|
||||||
profileMasterCvBody: "Upload a PDF, Word document, plain text file, markdown file, or image scan. Where supported, the app can extract text automatically and populate your master CV text for tailoring and outreach.",
|
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, or markdown file. The app extracts text where supported and populates your master CV text for tailoring and outreach.",
|
||||||
profileUploadCv: "Upload CV",
|
profileUploadCv: "Upload CV",
|
||||||
profileUploading: "Uploading...",
|
profileUploading: "Uploading...",
|
||||||
profileCopyCvText: "Copy CV text",
|
profileCopyCvText: "Copy CV text",
|
||||||
@@ -179,7 +179,7 @@ export const translations = {
|
|||||||
profileCvUploadFailed: "Failed to upload CV.",
|
profileCvUploadFailed: "Failed to upload CV.",
|
||||||
profileCvTextLabel: "Profile CV / master resume text",
|
profileCvTextLabel: "Profile CV / master resume text",
|
||||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||||
profileCvPreferredUploads: "Preferred uploads: PDF, DOC, DOCX. Text and image files are also accepted.",
|
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD.",
|
||||||
profileSaveChanges: "Save changes",
|
profileSaveChanges: "Save changes",
|
||||||
profileUpdated: "Profile updated.",
|
profileUpdated: "Profile updated.",
|
||||||
profileUpdateFailed: "Failed to update profile.",
|
profileUpdateFailed: "Failed to update profile.",
|
||||||
@@ -591,7 +591,7 @@ export const translations = {
|
|||||||
profileHeadline: "Profiloverskrift",
|
profileHeadline: "Profiloverskrift",
|
||||||
profileHeadlineHelp: "Lagres bare i denne nettleseren for å gjøre arbeidsområdet mer personlig.",
|
profileHeadlineHelp: "Lagres bare i denne nettleseren for å gjøre arbeidsområdet mer personlig.",
|
||||||
profileMasterCv: "Hoved-CV",
|
profileMasterCv: "Hoved-CV",
|
||||||
profileMasterCvBody: "Last opp en PDF, et Word-dokument, en ren tekstfil, en markdown-fil eller et bildeskann. Der det støttes kan appen automatisk hente ut tekst og fylle inn hoved-CV-en din for tilpasning og kontakt.",
|
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil eller markdown-fil. Appen henter ut tekst der det støttes og fyller inn hoved-CV-en din for tilpasning og kontakt.",
|
||||||
profileUploadCv: "Last opp CV",
|
profileUploadCv: "Last opp CV",
|
||||||
profileUploading: "Laster opp...",
|
profileUploading: "Laster opp...",
|
||||||
profileCopyCvText: "Kopier CV-tekst",
|
profileCopyCvText: "Kopier CV-tekst",
|
||||||
@@ -599,7 +599,7 @@ export const translations = {
|
|||||||
profileCvUploadFailed: "Kunne ikke laste opp CV.",
|
profileCvUploadFailed: "Kunne ikke laste opp CV.",
|
||||||
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
||||||
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
||||||
profileCvPreferredUploads: "Foretrukne opplastinger: PDF, DOC, DOCX. Tekst- og bildefiler aksepteres også.",
|
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD.",
|
||||||
profileSaveChanges: "Lagre endringer",
|
profileSaveChanges: "Lagre endringer",
|
||||||
profileUpdated: "Profil oppdatert.",
|
profileUpdated: "Profil oppdatert.",
|
||||||
profileUpdateFailed: "Kunne ikke oppdatere profil.",
|
profileUpdateFailed: "Kunne ikke oppdatere profil.",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
type SummarizerMetrics = {
|
type SummarizerMetrics = {
|
||||||
@@ -130,7 +130,7 @@ export default function AdminSystemPage() {
|
|||||||
const res = await api.get<SystemStatus>("/admin/system");
|
const res = await api.get<SystemStatus>("/admin/system");
|
||||||
setStatus(res.data);
|
setStatus(res.data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data || e?.message || "Failed to load system status.");
|
setError(getApiErrorMessage(e, "Failed to load system status."));
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -164,7 +164,7 @@ export default function AdminSystemPage() {
|
|||||||
message: testEmailMessage.trim() || null,
|
message: testEmailMessage.trim() || null,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data || e?.message || "Failed to send test email.");
|
setError(getApiErrorMessage(e, "Failed to send test email."));
|
||||||
} finally {
|
} finally {
|
||||||
setSendingTestEmail(false);
|
setSendingTestEmail(false);
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ export default function AdminSystemPage() {
|
|||||||
await api.post("/admin/system/summarizer/probe");
|
await api.post("/admin/system/summarizer/probe");
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data || e?.message || "Failed to run summarizer probe.");
|
setError(getApiErrorMessage(e, "Failed to run summarizer probe."));
|
||||||
} finally {
|
} finally {
|
||||||
setRunningProbe(false);
|
setRunningProbe(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type MeResponse = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CV_UPLOAD_ACCEPT = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||||
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
|
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||||
|
|
||||||
function initialsFrom(values: Array<string | undefined>) {
|
function initialsFrom(values: Array<string | undefined>) {
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
|
|||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
|
lineHeight: 1.45,
|
||||||
},
|
},
|
||||||
inputMultiline: {
|
inputMultiline: {
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
@@ -295,6 +296,11 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
|
|||||||
},
|
},
|
||||||
MuiInputBase: {
|
MuiInputBase: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
input: {
|
||||||
|
"&::placeholder": {
|
||||||
|
opacity: 0.72,
|
||||||
|
},
|
||||||
|
},
|
||||||
inputMultiline: {
|
inputMultiline: {
|
||||||
"&::placeholder": {
|
"&::placeholder": {
|
||||||
opacity: 0.72,
|
opacity: 0.72,
|
||||||
|
|||||||
Reference in New Issue
Block a user