feat: structured salary fields (min/max/currency/period)
Adds SalaryMin/SalaryMax/SalaryCurrency/SalaryPeriod alongside the existing free-text Salary field (kept for back-compat and display). - JobApplication model + idempotent column bridging for SQLite and MySQL - Create/Update DTOs with NormalizeSalary (clamps negatives, swaps inverted min/max, uppercases currency, whitelists period) - JobApplicationDto exposes the fields; CSV export gains 4 columns - UI: add/edit dialogs get min/max/currency/period inputs; job table renders a formatted range via shared salary.ts formatter (falls back to free-text when structured values are absent) - EN/NB translations; backend + full frontend suites green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,115 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_normalizes_structured_salary()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var request = new JobApplicationsController.CreateJobApplicationRequest(
|
||||||
|
JobTitle: "Backend Dev",
|
||||||
|
CompanyId: company.Id,
|
||||||
|
Status: null,
|
||||||
|
Location: null,
|
||||||
|
Salary: "60-70k",
|
||||||
|
SalaryMin: 70000m, // min > max on purpose: normalization swaps them
|
||||||
|
SalaryMax: 60000m,
|
||||||
|
SalaryCurrency: " nok ",
|
||||||
|
SalaryPeriod: "YEAR",
|
||||||
|
NextAction: null,
|
||||||
|
FollowUpAt: null,
|
||||||
|
Notes: null,
|
||||||
|
Description: null,
|
||||||
|
TranslatedDescription: null,
|
||||||
|
DescriptionLanguage: null,
|
||||||
|
Tags: null,
|
||||||
|
Deadline: null,
|
||||||
|
CoverLetterText: null,
|
||||||
|
JobUrl: null,
|
||||||
|
DateApplied: null,
|
||||||
|
FeedbackRequestedAt: null,
|
||||||
|
HasResume: null,
|
||||||
|
HasCoverLetter: null,
|
||||||
|
HasPortfolio: null,
|
||||||
|
HasOtherAttachment: null);
|
||||||
|
|
||||||
|
var result = await controller.Create(request, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
var saved = await db.JobApplications.FirstAsync();
|
||||||
|
Assert.Equal(60000m, saved.SalaryMin);
|
||||||
|
Assert.Equal(70000m, saved.SalaryMax);
|
||||||
|
Assert.Equal("NOK", saved.SalaryCurrency);
|
||||||
|
Assert.Equal("year", saved.SalaryPeriod);
|
||||||
|
Assert.Equal("60-70k", saved.Salary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_drops_invalid_salary_period_and_negative_values()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Dev",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
SalaryMin = 50000m,
|
||||||
|
SalaryMax = 60000m,
|
||||||
|
SalaryCurrency = "NOK",
|
||||||
|
SalaryPeriod = "year",
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var request = new JobApplicationsController.UpdateJobApplicationRequest(
|
||||||
|
JobTitle: "Backend Dev",
|
||||||
|
CompanyId: company.Id,
|
||||||
|
Status: "Applied",
|
||||||
|
ResponseReceived: false,
|
||||||
|
ResponseDate: null,
|
||||||
|
Location: null,
|
||||||
|
Salary: null,
|
||||||
|
SalaryMin: -5m,
|
||||||
|
SalaryMax: null,
|
||||||
|
SalaryCurrency: "",
|
||||||
|
SalaryPeriod: "fortnight",
|
||||||
|
NextAction: null,
|
||||||
|
FollowUpAt: null,
|
||||||
|
HasResume: null,
|
||||||
|
HasCoverLetter: null,
|
||||||
|
HasPortfolio: null,
|
||||||
|
HasOtherAttachment: null,
|
||||||
|
Notes: null,
|
||||||
|
Description: null,
|
||||||
|
TranslatedDescription: null,
|
||||||
|
DescriptionLanguage: null,
|
||||||
|
Tags: null,
|
||||||
|
Deadline: null,
|
||||||
|
CoverLetterText: null,
|
||||||
|
JobUrl: null,
|
||||||
|
DateApplied: null,
|
||||||
|
FeedbackRequestedAt: null,
|
||||||
|
StatusChangedAt: null);
|
||||||
|
|
||||||
|
var result = await controller.Update(job.Id, request, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
var saved = await db.JobApplications.FirstAsync();
|
||||||
|
Assert.Null(saved.SalaryMin);
|
||||||
|
Assert.Null(saved.SalaryMax);
|
||||||
|
Assert.Null(saved.SalaryCurrency);
|
||||||
|
Assert.Null(saved.SalaryPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
||||||
{
|
{
|
||||||
var summarizer = new Mock<ISummarizerService>();
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ namespace JobTrackerApi.Controllers
|
|||||||
"DateApplied",
|
"DateApplied",
|
||||||
"Location",
|
"Location",
|
||||||
"Salary",
|
"Salary",
|
||||||
|
"SalaryMin",
|
||||||
|
"SalaryMax",
|
||||||
|
"SalaryCurrency",
|
||||||
|
"SalaryPeriod",
|
||||||
"NextAction",
|
"NextAction",
|
||||||
"FollowUpAt",
|
"FollowUpAt",
|
||||||
"JobUrl",
|
"JobUrl",
|
||||||
@@ -76,6 +80,10 @@ namespace JobTrackerApi.Controllers
|
|||||||
Esc(j.DateApplied.ToString("o")),
|
Esc(j.DateApplied.ToString("o")),
|
||||||
Esc(j.Location),
|
Esc(j.Location),
|
||||||
Esc(j.Salary),
|
Esc(j.Salary),
|
||||||
|
Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
Esc(j.SalaryCurrency),
|
||||||
|
Esc(j.SalaryPeriod),
|
||||||
Esc(j.NextAction),
|
Esc(j.NextAction),
|
||||||
Esc(j.FollowUpAt?.ToString("o")),
|
Esc(j.FollowUpAt?.ToString("o")),
|
||||||
Esc(j.JobUrl),
|
Esc(j.JobUrl),
|
||||||
|
|||||||
@@ -749,6 +749,10 @@ Canonical profile:
|
|||||||
Deadline: job.Deadline,
|
Deadline: job.Deadline,
|
||||||
Location: job.Location,
|
Location: job.Location,
|
||||||
Salary: job.Salary,
|
Salary: job.Salary,
|
||||||
|
SalaryMin: job.SalaryMin,
|
||||||
|
SalaryMax: job.SalaryMax,
|
||||||
|
SalaryCurrency: job.SalaryCurrency,
|
||||||
|
SalaryPeriod: job.SalaryPeriod,
|
||||||
NextAction: job.NextAction,
|
NextAction: job.NextAction,
|
||||||
FollowUpAt: job.FollowUpAt,
|
FollowUpAt: job.FollowUpAt,
|
||||||
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
||||||
@@ -1081,6 +1085,10 @@ Canonical profile:
|
|||||||
DateTime? Deadline,
|
DateTime? Deadline,
|
||||||
string? Location,
|
string? Location,
|
||||||
string? Salary,
|
string? Salary,
|
||||||
|
decimal? SalaryMin,
|
||||||
|
decimal? SalaryMax,
|
||||||
|
string? SalaryCurrency,
|
||||||
|
string? SalaryPeriod,
|
||||||
string? NextAction,
|
string? NextAction,
|
||||||
DateTime? FollowUpAt,
|
DateTime? FollowUpAt,
|
||||||
DateTime? FeedbackRequestedAt,
|
DateTime? FeedbackRequestedAt,
|
||||||
@@ -1349,6 +1357,10 @@ Canonical profile:
|
|||||||
string? Status,
|
string? Status,
|
||||||
string? Location,
|
string? Location,
|
||||||
string? Salary,
|
string? Salary,
|
||||||
|
decimal? SalaryMin,
|
||||||
|
decimal? SalaryMax,
|
||||||
|
string? SalaryCurrency,
|
||||||
|
string? SalaryPeriod,
|
||||||
string? NextAction,
|
string? NextAction,
|
||||||
DateTime? FollowUpAt,
|
DateTime? FollowUpAt,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
@@ -1367,6 +1379,22 @@ Canonical profile:
|
|||||||
bool? HasOtherAttachment
|
bool? HasOtherAttachment
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
||||||
|
decimal? min, decimal? max, string? currency, string? period)
|
||||||
|
{
|
||||||
|
if (min is < 0) min = null;
|
||||||
|
if (max is < 0) max = null;
|
||||||
|
if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min);
|
||||||
|
|
||||||
|
var cur = (currency ?? "").Trim().ToUpperInvariant();
|
||||||
|
if (cur.Length > 8) cur = cur[..8];
|
||||||
|
|
||||||
|
var per = (period ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (per is not ("year" or "month" or "hour")) per = "";
|
||||||
|
|
||||||
|
return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -1409,6 +1437,9 @@ Canonical profile:
|
|||||||
ResponseDate = null,
|
ResponseDate = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
|
||||||
|
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
|
||||||
|
|
||||||
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1447,6 +1478,10 @@ Canonical profile:
|
|||||||
DateTime? ResponseDate,
|
DateTime? ResponseDate,
|
||||||
string? Location,
|
string? Location,
|
||||||
string? Salary,
|
string? Salary,
|
||||||
|
decimal? SalaryMin,
|
||||||
|
decimal? SalaryMax,
|
||||||
|
string? SalaryCurrency,
|
||||||
|
string? SalaryPeriod,
|
||||||
string? NextAction,
|
string? NextAction,
|
||||||
DateTime? FollowUpAt,
|
DateTime? FollowUpAt,
|
||||||
bool? HasResume,
|
bool? HasResume,
|
||||||
@@ -1487,6 +1522,8 @@ Canonical profile:
|
|||||||
job.ResponseDate = request.ResponseDate;
|
job.ResponseDate = request.ResponseDate;
|
||||||
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
|
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
|
||||||
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
|
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
|
||||||
|
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
|
||||||
|
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
|
||||||
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
||||||
job.FollowUpAt = request.FollowUpAt;
|
job.FollowUpAt = request.FollowUpAt;
|
||||||
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
||||||
|
|||||||
@@ -484,6 +484,12 @@ public static class StartupInitializationExtensions
|
|||||||
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt 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;");
|
||||||
|
|
||||||
|
// Structured salary fields (EF maps decimal to TEXT on SQLite).
|
||||||
|
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
|
||||||
|
|
||||||
// Ensure ownership columns exist even on non-legacy DBs.
|
// Ensure ownership columns exist even on non-legacy DBs.
|
||||||
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
||||||
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
|
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
|
||||||
@@ -607,6 +613,10 @@ public static class StartupInitializationExtensions
|
|||||||
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
|
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ public class JobApplication
|
|||||||
public DateTime DateApplied { get; set; } = DateTime.UtcNow;
|
public DateTime DateApplied { get; set; } = DateTime.UtcNow;
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
public string? Salary { get; set; }
|
public string? Salary { get; set; }
|
||||||
|
|
||||||
|
// Structured salary; the free-text Salary field is kept for display/back-compat.
|
||||||
|
public decimal? SalaryMin { get; set; }
|
||||||
|
public decimal? SalaryMax { get; set; }
|
||||||
|
public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR"
|
||||||
|
public string? SalaryPeriod { get; set; } // "year" | "month" | "hour"
|
||||||
public string? NextAction { get; set; }
|
public string? NextAction { get; set; }
|
||||||
public DateTime? FollowUpAt { get; set; }
|
public DateTime? FollowUpAt { get; set; }
|
||||||
public DateTime? FeedbackRequestedAt { get; set; }
|
public DateTime? FeedbackRequestedAt { get; set; }
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
|
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [salary, setSalary] = useState("");
|
const [salary, setSalary] = useState("");
|
||||||
|
const [salaryMin, setSalaryMin] = useState("");
|
||||||
|
const [salaryMax, setSalaryMax] = useState("");
|
||||||
|
const [salaryCurrency, setSalaryCurrency] = useState("");
|
||||||
|
const [salaryPeriod, setSalaryPeriod] = useState("");
|
||||||
const [jobUrl, setJobUrl] = useState("");
|
const [jobUrl, setJobUrl] = useState("");
|
||||||
const [deadline, setDeadline] = useState("");
|
const [deadline, setDeadline] = useState("");
|
||||||
|
|
||||||
@@ -291,6 +295,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
status,
|
status,
|
||||||
location,
|
location,
|
||||||
salary,
|
salary,
|
||||||
|
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||||
|
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||||
|
salaryCurrency: salaryCurrency.trim() || null,
|
||||||
|
salaryPeriod: salaryPeriod || null,
|
||||||
nextAction: null,
|
nextAction: null,
|
||||||
followUpAt: null,
|
followUpAt: null,
|
||||||
jobUrl,
|
jobUrl,
|
||||||
@@ -482,6 +490,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||||
|
|
||||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||||
|
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
|
||||||
|
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
|
||||||
|
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
|
||||||
|
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
|
||||||
|
<option value=""></option>
|
||||||
|
<option value="year">{t("salaryPeriodYear")}</option>
|
||||||
|
<option value="month">{t("salaryPeriodMonth")}</option>
|
||||||
|
<option value="hour">{t("salaryPeriodHour")}</option>
|
||||||
|
</TextField>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
label={t("addJobModalDeadline")}
|
label={t("addJobModalDeadline")}
|
||||||
value={parsePickerDate(deadline)}
|
value={parsePickerDate(deadline)}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [salary, setSalary] = useState("");
|
const [salary, setSalary] = useState("");
|
||||||
|
const [salaryMin, setSalaryMin] = useState("");
|
||||||
|
const [salaryMax, setSalaryMax] = useState("");
|
||||||
|
const [salaryCurrency, setSalaryCurrency] = useState("");
|
||||||
|
const [salaryPeriod, setSalaryPeriod] = useState("");
|
||||||
const [nextAction, setNextAction] = useState("");
|
const [nextAction, setNextAction] = useState("");
|
||||||
const [followUpAt, setFollowUpAt] = useState<string>("");
|
const [followUpAt, setFollowUpAt] = useState<string>("");
|
||||||
const [jobUrl, setJobUrl] = useState("");
|
const [jobUrl, setJobUrl] = useState("");
|
||||||
@@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
setDateApplied(toDateInputValue(j.dateApplied));
|
setDateApplied(toDateInputValue(j.dateApplied));
|
||||||
setLocation(j.location ?? "");
|
setLocation(j.location ?? "");
|
||||||
setSalary(j.salary ?? "");
|
setSalary(j.salary ?? "");
|
||||||
|
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
|
||||||
|
setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : "");
|
||||||
|
setSalaryCurrency(j.salaryCurrency ?? "");
|
||||||
|
setSalaryPeriod(j.salaryPeriod ?? "");
|
||||||
setNextAction((j as any).nextAction ?? "");
|
setNextAction((j as any).nextAction ?? "");
|
||||||
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
||||||
setJobUrl(j.jobUrl ?? "");
|
setJobUrl(j.jobUrl ?? "");
|
||||||
@@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
responseDate: responseReceived && responseDate ? responseDate : null,
|
responseDate: responseReceived && responseDate ? responseDate : null,
|
||||||
location: location.trim() || null,
|
location: location.trim() || null,
|
||||||
salary: salary.trim() || null,
|
salary: salary.trim() || null,
|
||||||
|
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||||
|
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||||
|
salaryCurrency: salaryCurrency.trim() || null,
|
||||||
|
salaryPeriod: salaryPeriod || null,
|
||||||
nextAction: nextAction.trim() || null,
|
nextAction: nextAction.trim() || null,
|
||||||
followUpAt: followUpAt || null,
|
followUpAt: followUpAt || null,
|
||||||
hasResume,
|
hasResume,
|
||||||
@@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||||
|
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
|
||||||
|
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
|
||||||
|
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
|
||||||
|
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
|
||||||
|
<option value=""></option>
|
||||||
|
<option value="year">{t("salaryPeriodYear")}</option>
|
||||||
|
<option value="month">{t("salaryPeriodMonth")}</option>
|
||||||
|
<option value="hour">{t("salaryPeriodHour")}</option>
|
||||||
|
</TextField>
|
||||||
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||||
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
|
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { api } from "../api";
|
|||||||
import ViewStateNotice from "./ViewStateNotice";
|
import ViewStateNotice from "./ViewStateNotice";
|
||||||
import { useCompanies } from "../hooks/useCompanies";
|
import { useCompanies } from "../hooks/useCompanies";
|
||||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||||
|
import { formatSalary } from "../salary";
|
||||||
import JobDetailsDialog from "./JobDetailsDialog";
|
import JobDetailsDialog from "./JobDetailsDialog";
|
||||||
import EditJobDialog from "./EditJobDialog";
|
import EditJobDialog from "./EditJobDialog";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -584,7 +585,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{job.salary ?? "-"}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -727,7 +728,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{formatSalary(job) ?? "-"}</Typography></Box>
|
||||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{detailTags.length ? detailTags.map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{detailTags.length ? detailTags.map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
|
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ export const translations = {
|
|||||||
addJobModalStatus: "Status",
|
addJobModalStatus: "Status",
|
||||||
addJobModalJobTitle: "Job title",
|
addJobModalJobTitle: "Job title",
|
||||||
addJobModalSalary: "Salary",
|
addJobModalSalary: "Salary",
|
||||||
|
salaryMinLabel: "Salary min",
|
||||||
|
salaryMaxLabel: "Salary max",
|
||||||
|
salaryCurrencyLabel: "Currency",
|
||||||
|
salaryPeriodLabel: "Per",
|
||||||
|
salaryPeriodYear: "Year",
|
||||||
|
salaryPeriodMonth: "Month",
|
||||||
|
salaryPeriodHour: "Hour",
|
||||||
addJobModalDeadline: "Deadline",
|
addJobModalDeadline: "Deadline",
|
||||||
addJobModalDescriptionOriginal: "Description (original)",
|
addJobModalDescriptionOriginal: "Description (original)",
|
||||||
addJobModalTranslatedDescription: "Translated description ({language})",
|
addJobModalTranslatedDescription: "Translated description ({language})",
|
||||||
@@ -987,6 +994,13 @@ export const translations = {
|
|||||||
addJobModalStatus: "Status",
|
addJobModalStatus: "Status",
|
||||||
addJobModalJobTitle: "Stillingstittel",
|
addJobModalJobTitle: "Stillingstittel",
|
||||||
addJobModalSalary: "Lønn",
|
addJobModalSalary: "Lønn",
|
||||||
|
salaryMinLabel: "Lønn fra",
|
||||||
|
salaryMaxLabel: "Lønn til",
|
||||||
|
salaryCurrencyLabel: "Valuta",
|
||||||
|
salaryPeriodLabel: "Per",
|
||||||
|
salaryPeriodYear: "År",
|
||||||
|
salaryPeriodMonth: "Måned",
|
||||||
|
salaryPeriodHour: "Time",
|
||||||
addJobModalDeadline: "Frist",
|
addJobModalDeadline: "Frist",
|
||||||
addJobModalDescriptionOriginal: "Beskrivelse (original)",
|
addJobModalDescriptionOriginal: "Beskrivelse (original)",
|
||||||
addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})",
|
addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { JobApplication } from "./types";
|
||||||
|
|
||||||
|
type SalaryFields = Pick<JobApplication, "salary" | "salaryMin" | "salaryMax" | "salaryCurrency" | "salaryPeriod">;
|
||||||
|
|
||||||
|
const PERIOD_SUFFIX: Record<string, string> = { year: "yr", month: "mo", hour: "hr" };
|
||||||
|
|
||||||
|
/** Structured salary when present ("60 000–70 000 NOK/yr"), otherwise the free-text field. */
|
||||||
|
export function formatSalary(job: SalaryFields): string | null {
|
||||||
|
const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job;
|
||||||
|
if (salaryMin == null && salaryMax == null) {
|
||||||
|
return job.salary?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (value: number) => value.toLocaleString();
|
||||||
|
const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax
|
||||||
|
? `${fmt(salaryMin)}–${fmt(salaryMax)}`
|
||||||
|
: fmt((salaryMin ?? salaryMax) as number);
|
||||||
|
const currency = salaryCurrency ? ` ${salaryCurrency}` : "";
|
||||||
|
const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : "";
|
||||||
|
return `${range}${currency}${period}`;
|
||||||
|
}
|
||||||
@@ -89,6 +89,10 @@ export interface JobApplication {
|
|||||||
dateApplied: string;
|
dateApplied: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
salary?: string;
|
salary?: string;
|
||||||
|
salaryMin?: number | null;
|
||||||
|
salaryMax?: number | null;
|
||||||
|
salaryCurrency?: string | null;
|
||||||
|
salaryPeriod?: string | null;
|
||||||
nextAction?: string;
|
nextAction?: string;
|
||||||
followUpAt?: string;
|
followUpAt?: string;
|
||||||
feedbackRequestedAt?: string;
|
feedbackRequestedAt?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user