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:
@@ -118,6 +118,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
|
||||
const [location, setLocation] = 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 [deadline, setDeadline] = useState("");
|
||||
|
||||
@@ -291,6 +295,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
status,
|
||||
location,
|
||||
salary,
|
||||
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||
salaryCurrency: salaryCurrency.trim() || null,
|
||||
salaryPeriod: salaryPeriod || null,
|
||||
nextAction: null,
|
||||
followUpAt: null,
|
||||
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("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("addJobModalDeadline")}
|
||||
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 [location, setLocation] = 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 [followUpAt, setFollowUpAt] = useState<string>("");
|
||||
const [jobUrl, setJobUrl] = useState("");
|
||||
@@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setDateApplied(toDateInputValue(j.dateApplied));
|
||||
setLocation(j.location ?? "");
|
||||
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 ?? "");
|
||||
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
||||
setJobUrl(j.jobUrl ?? "");
|
||||
@@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
responseDate: responseReceived && responseDate ? responseDate : null,
|
||||
location: location.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,
|
||||
followUpAt: followUpAt || null,
|
||||
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 }}>
|
||||
<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("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 }} />
|
||||
<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>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { formatSalary } from "../salary";
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
import EditJobDialog from "./EditJobDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -584,7 +585,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
<Box>
|
||||
<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>
|
||||
|
||||
@@ -727,7 +728,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<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("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 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>
|
||||
|
||||
@@ -77,6 +77,13 @@ export const translations = {
|
||||
addJobModalStatus: "Status",
|
||||
addJobModalJobTitle: "Job title",
|
||||
addJobModalSalary: "Salary",
|
||||
salaryMinLabel: "Salary min",
|
||||
salaryMaxLabel: "Salary max",
|
||||
salaryCurrencyLabel: "Currency",
|
||||
salaryPeriodLabel: "Per",
|
||||
salaryPeriodYear: "Year",
|
||||
salaryPeriodMonth: "Month",
|
||||
salaryPeriodHour: "Hour",
|
||||
addJobModalDeadline: "Deadline",
|
||||
addJobModalDescriptionOriginal: "Description (original)",
|
||||
addJobModalTranslatedDescription: "Translated description ({language})",
|
||||
@@ -987,6 +994,13 @@ export const translations = {
|
||||
addJobModalStatus: "Status",
|
||||
addJobModalJobTitle: "Stillingstittel",
|
||||
addJobModalSalary: "Lønn",
|
||||
salaryMinLabel: "Lønn fra",
|
||||
salaryMaxLabel: "Lønn til",
|
||||
salaryCurrencyLabel: "Valuta",
|
||||
salaryPeriodLabel: "Per",
|
||||
salaryPeriodYear: "År",
|
||||
salaryPeriodMonth: "Måned",
|
||||
salaryPeriodHour: "Time",
|
||||
addJobModalDeadline: "Frist",
|
||||
addJobModalDescriptionOriginal: "Beskrivelse (original)",
|
||||
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;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
salaryMin?: number | null;
|
||||
salaryMax?: number | null;
|
||||
salaryCurrency?: string | null;
|
||||
salaryPeriod?: string | null;
|
||||
nextAction?: string;
|
||||
followUpAt?: string;
|
||||
feedbackRequestedAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user