feat(ui): migrate recorded offer comparison

This commit is contained in:
cesnimda
2026-10-13 13:19:53 +02:00
parent a8aba85299
commit c723a4e303
14 changed files with 2935 additions and 42 deletions
+10 -4
View File
@@ -1653,12 +1653,15 @@ export const translations = {
offerComparisonNav: "Compare offers",
offerComparisonTitle: "Offer comparison",
offerComparisonPageSubtitle: "Compare active offers using the facts already recorded on each application.",
offerComparisonHelp: "Review compensation, location, decision dates, next actions and your own notes side by side. Missing details stay visibly unknown.",
offerComparisonHelp: "Review compensation, location, recorded application dates and deadlines, next actions and your own notes side by side. Missing details stay visibly unknown.",
offerComparisonSampleCount: "Showing {shown} of {total} active offers",
offerComparisonSampleHelp: "This comparison shows the first 25 offers by recorded application date. Open all offers to review the complete list.",
offerComparisonAllOffers: "Open all offers",
offerComparisonLoadFailed: "Could not load active offers.",
offerComparisonEmpty: "No active offers to compare",
offerComparisonEmptyHelp: "When an application reaches Offer, it will appear here automatically.",
offerComparisonCompensation: "Compensation",
offerComparisonDecisionDeadline: "Decision deadline",
offerComparisonDecisionDeadline: "Recorded application deadline",
offerComparisonNotes: "Notes and considerations",
offerComparisonNotRecorded: "Not recorded",
adminUsersTitle: "Users",
@@ -4295,12 +4298,15 @@ export const translations = {
offerComparisonNav: "Sammenlign tilbud",
offerComparisonTitle: "Sammenligning av tilbud",
offerComparisonPageSubtitle: "Sammenlign aktive tilbud med opplysningene som allerede er registrert i hver søknad.",
offerComparisonHelp: "Se lønn, sted, svarfrister, neste handling og egne notater side om side. Manglende opplysninger vises tydelig som ukjente.",
offerComparisonHelp: "Se lønn, sted, registrerte søknadsdatoer og frister, neste handling og egne notater side om side. Manglende opplysninger vises tydelig som ukjente.",
offerComparisonSampleCount: "Viser {shown} av {total} aktive tilbud",
offerComparisonSampleHelp: "Sammenligningen viser de første 25 tilbudene etter registrert søknadsdato. Åpne alle tilbud for å se hele listen.",
offerComparisonAllOffers: "Åpne alle tilbud",
offerComparisonLoadFailed: "Kunne ikke laste aktive tilbud.",
offerComparisonEmpty: "Ingen aktive tilbud å sammenligne",
offerComparisonEmptyHelp: "Når en søknad får status Tilbud, vises den automatisk her.",
offerComparisonCompensation: "Kompensasjon",
offerComparisonDecisionDeadline: "Svarfrist",
offerComparisonDecisionDeadline: "Registrert søknadsfrist",
offerComparisonNotes: "Notater og vurderinger",
offerComparisonNotRecorded: "Ikke registrert",
adminUsersTitle: "Brukere",
+210 -7
View File
@@ -1,34 +1,237 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations, type UiLanguage } from "./i18n/translations";
import type { JobApplication } from "./types";
import OfferComparisonPage from "./views/OfferComparisonPage";
jest.mock("./api", () => ({
api: { get: jest.fn() },
api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
const request = { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "dateApplied", sortDir: "asc" } };
beforeEach(() => {
window.localStorage.clear();
jest.clearAllMocks();
mockedApi.get.mockReset();
});
afterEach(() => jest.restoreAllMocks());
function copy(key: string, language: UiLanguage = "en", params: Record<string, number> = {}) {
const template = (translations[language] as Record<string, string>)[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function offer(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
id, jobTitle: `Offer role ${id}`, company: { id, name: `Company ${id}` }, status: "Offer",
dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10,
...overrides,
};
}
function paged(items: JobApplication[], total = items.length) {
return { data: { items, total, page: 1, pageSize: 25 } };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function RouteContext() {
const location = useLocation();
return <output aria-label="Current route">{location.pathname}{location.search}{location.hash}</output>;
}
function renderPage(language: UiLanguage = "en") {
window.localStorage.setItem("uiLanguage", language);
return render(<I18nProvider><MemoryRouter initialEntries={["/offers"]}><RouteContext /><OfferComparisonPage /></MemoryRouter></I18nProvider>);
}
function changeLanguage(language: UiLanguage) {
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); });
}
function assertNoWrites() {
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
}
test("compares recorded offer facts without filling missing values", async () => {
mockedApi.get.mockResolvedValue({ data: { items: [
{ id: 1, jobTitle: "Backend Engineer", company: { id: 1, name: "Acme" }, status: "Offer", salaryMin: 800000, salaryMax: 900000, salaryCurrency: "NOK", salaryPeriod: "year", location: "Oslo", deadline: "2026-09-10", nextAction: "Review contract", notes: "Hybrid three days", dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10 },
{ id: 2, jobTitle: "Platform Engineer", company: { id: 2, name: "Beta" }, status: "Offer", dateApplied: "2026-08-02", savedAt: "2026-07-21", responseReceived: true, daysSince: 9 },
], total: 2, page: 1, pageSize: 25 } } as any);
render(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
renderPage();
expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
expect(screen.getByText("800,000 – 900,000 NOK / year")).toBeInTheDocument();
expect(screen.getByText(`800,000 – 900,000 NOK / ${translations.en.salaryPeriodYear}`)).toBeInTheDocument();
expect(screen.getByText("Hybrid three days")).toBeInTheDocument();
expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(0);
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } });
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request);
assertNoWrites();
});
test("shows an honest empty state when there are no offers", async () => {
mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 25 } } as any);
render(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
renderPage();
expect(await screen.findByText("No active offers to compare")).toBeInTheDocument();
assertNoWrites();
});
test("shows the first 25 of the real total in server application-date order without inventing the remaining offers", async () => {
const items = Array.from({ length: 25 }, (_, index) => offer(index + 1, {
dateApplied: `2026-08-${String(index + 1).padStart(2, "0")}`,
deadline: index === 0 ? "2026-12-01" : "2026-09-01",
}));
mockedApi.get.mockResolvedValue(paged(items, 61) as any);
renderPage();
expect(await screen.findByText(copy("offerComparisonSampleCount", "en", { shown: 25, total: 61 }))).toBeInTheDocument();
expect(screen.getByText(copy("offerComparisonSampleHelp"))).toBeInTheDocument();
expect(screen.getAllByRole("article")).toHaveLength(25);
expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(items.map((item) => item.jobTitle));
expect(screen.queryByText("Offer role 26")).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", request);
assertNoWrites();
});
test("opens the full recorded offer source with the same supported filter and ordering", async () => {
mockedApi.get.mockResolvedValue(paged([offer(7)], 42) as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: copy("offerComparisonAllOffers") }));
const route = new URL(screen.getByLabelText("Current route").textContent!, "https://example.test");
expect(route.pathname).toBe("/jobs");
expect(Object.fromEntries(route.searchParams.entries())).toEqual({ status: "Offer", sortBy: "dateApplied", sortDir: "asc" });
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("distinguishes held and failed reads from empty results and recovers through one explicit identical retry", async () => {
const initial = deferred<ReturnType<typeof paged>>();
const retry = deferred<ReturnType<typeof paged>>();
mockedApi.get.mockImplementationOnce(() => initial.promise as any).mockImplementationOnce(() => retry.promise as any);
renderPage();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { initial.reject({ response: { status: 503 } }); });
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed);
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: translations.en.retry }));
expect(screen.queryByRole("button", { name: translations.en.retry })).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenNthCalledWith(1, "/jobapplications", request);
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request);
await act(async () => { retry.resolve(paged([offer(91)])); });
expect(await screen.findByRole("article", { name: "Offer role 91" })).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.offerComparisonEmpty)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
assertNoWrites();
});
test("a live locale change translates loaded offer chrome and preserves recorded facts without another GET", async () => {
const notes = "Recorded English notes stay unchanged — æøå";
mockedApi.get.mockResolvedValue(paged([offer(8, { jobTitle: "English source title", notes, salary: "GBP package as recorded" })]) as any);
renderPage();
expect(await screen.findByText(notes)).toBeInTheDocument();
changeLanguage("nb");
expect(screen.getByRole("heading", { level: 2, name: translations.nb.offerComparisonTitle })).toBeInTheDocument();
const article = screen.getByRole("article", { name: "English source title" });
expect(within(article).getByText(notes)).toBeInTheDocument();
expect(within(article).getByText("GBP package as recorded")).toBeInTheDocument();
expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("an existing read failure translates without refetching and Retry retains the same query", async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce(paged([offer(9)]) as any);
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.offerComparisonLoadFailed);
changeLanguage("nb");
expect(screen.getByRole("alert")).toHaveTextContent(translations.nb.offerComparisonLoadFailed);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: translations.nb.retry }));
expect(await screen.findByRole("article", { name: "Offer role 9" })).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/jobapplications", request);
expect(mockedApi.get).toHaveBeenCalledTimes(2);
assertNoWrites();
});
test("structured salaries follow UI locale rather than host locale while preserving zero, bounds, currency, unknown periods and free text", async () => {
const originalLocaleString = Number.prototype.toLocaleString;
jest.spyOn(Number.prototype, "toLocaleString").mockImplementation(function (
this: number,
locales?: Parameters<typeof Number.prototype.toLocaleString>[0],
options?: Parameters<typeof Number.prototype.toLocaleString>[1],
) {
return originalLocaleString.call(this, locales ?? "de-DE", options);
});
const rawSalary = " Negotiable — no numeric estimate ";
const items = [
offer(11, { salaryMin: 0, salaryCurrency: "NOK", salaryPeriod: "year" }),
offer(12, { salaryMin: 850000, salaryCurrency: "NOK", salaryPeriod: "month" }),
offer(13, { salaryMax: 900000, salaryCurrency: "USD", salaryPeriod: "hour" }),
offer(14, { salaryMin: 800000, salaryMax: 800000, salaryPeriod: "fortnight" }),
offer(15, { salary: rawSalary }),
offer(16, { salaryMin: 800000, salaryMax: 900000, salaryCurrency: "SEK" }),
offer(17, { salaryMin: 700000, salaryCurrency: "NOK", salaryPeriod: "constructor" }),
];
mockedApi.get.mockResolvedValue(paged(items) as any);
renderPage();
await screen.findByRole("article", { name: items[0].jobTitle });
const salaryText = (id: number) => {
const article = screen.getByRole("article", { name: `Offer role ${id}` });
const label = within(article).getByText(copy("offerComparisonCompensation", document.documentElement.lang === "nb-NO" ? "nb" : "en"));
return label.parentElement!.textContent!.replace(label.textContent!, "");
};
const normalizeSpaces = (value: string) => value.replace(/\s+/g, " ").trim();
for (const language of ["en", "nb"] as const) {
if (language === "nb") changeLanguage(language);
const fmt = (value: number) => new Intl.NumberFormat(language === "nb" ? "nb-NO" : "en").format(value);
expect(normalizeSpaces(salaryText(11))).toBe(`0 NOK / ${translations[language].salaryPeriodYear}`);
expect(normalizeSpaces(salaryText(12))).toBe(normalizeSpaces(`${fmt(850000)} NOK / ${translations[language].salaryPeriodMonth}`));
expect(normalizeSpaces(salaryText(13))).toBe(normalizeSpaces(`${fmt(900000)} USD / ${translations[language].salaryPeriodHour}`));
expect(normalizeSpaces(salaryText(14))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(800000)} / fortnight`));
expect(salaryText(15)).toBe(rawSalary);
expect(normalizeSpaces(salaryText(16))).toBe(normalizeSpaces(`${fmt(800000)} – ${fmt(900000)} SEK`));
expect(normalizeSpaces(salaryText(17))).toBe(normalizeSpaces(`${fmt(700000)} NOK / constructor`));
}
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
test("uses recorded applied/deadline/contact dates, keeps absent applied dates unknown, and opens the exact current application", async () => {
const item = offer(402, { jobTitle: "Recorded role", dateApplied: "2026-08-01", deadline: "2026-09-10", followUpAt: "2026-09-12" });
const missing = offer(407, { jobTitle: "No applied date", dateApplied: null, savedAt: "2026-07-20" });
mockedApi.get.mockResolvedValue(paged([item, missing]) as any);
renderPage("nb");
const article = await screen.findByRole("article", { name: item.jobTitle });
const fact = (label: string) => within(article).getByText(label).parentElement!;
const date = (value: string) => new Date(value).toLocaleDateString("nb-NO");
expect(fact(translations.nb.jobTableDateApplied)).toHaveTextContent(date(item.dateApplied!));
expect(fact(translations.nb.offerComparisonDecisionDeadline)).toHaveTextContent(date(item.deadline!));
expect(fact(translations.nb.companiesNextContact)).toHaveTextContent(date(item.followUpAt!));
expect(within(article).getByText(translations.nb.statusOffer)).toBeInTheDocument();
const missingArticle = screen.getByRole("article", { name: missing.jobTitle });
expect(within(missingArticle).getByText(translations.nb.jobTableDateApplied).parentElement).toHaveTextContent("—");
expect(within(missingArticle).queryByText(date(missing.savedAt))).not.toBeInTheDocument();
fireEvent.click(within(article).getByRole("button", { name: translations.nb.workspaceOpenFull }));
await waitFor(() => expect(screen.getByLabelText("Current route")).toHaveTextContent(/^\/jobs\/402$/));
expect(mockedApi.get).toHaveBeenCalledTimes(1);
assertNoWrites();
});
@@ -1,9 +1,11 @@
import { useCallback } from "react";
import { Alert, Box, Button, Chip, Paper, Skeleton, Stack, Typography } from "@mui/material";
import { useCallback, useRef } from "react";
import { Box, Button, Chip, Paper, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import { useViewResource } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
import ViewStateNotice from "../components/ViewStateNotice";
import { PageHeader } from "../layout/PageLayout";
import type { JobApplication } from "../types";
type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSize: number };
@@ -11,44 +13,60 @@ type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSiz
export default function OfferComparisonPage() {
const navigate = useNavigate();
const { language, t } = useI18n();
const load = useCallback(() => api.get<PagedJobs>("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } }).then((r) => r.data), []);
const resource = useViewResource(load, { initialData: { items: [], total: 0, page: 1, pageSize: 25 }, errorMessage: t("offerComparisonLoadFailed"), deps: [t] });
const date = (value?: string | null) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—";
const load = useCallback(() => api.get<PagedJobs>("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "dateApplied", sortDir: "asc" } }).then((r) => r.data), []);
const resource = useViewResource(load, { initialData: { items: [], total: 0, page: 1, pageSize: 25 }, errorMessage: "" });
const resultsRef = useRef<HTMLDivElement>(null);
const retryPending = useRef(false);
const loading = resource.loading || resource.refreshing;
const locale = language === "nb" ? "nb-NO" : "en";
const date = (value?: string | null) => value ? new Date(value).toLocaleDateString(locale) : "—";
const periods = new Map([["year", t("salaryPeriodYear")], ["month", t("salaryPeriodMonth")], ["hour", t("salaryPeriodHour")]]);
return (
<Stack spacing={2.5}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("offerComparisonTitle")}</Typography>
<Typography color="text.secondary">{t("offerComparisonHelp")}</Typography>
</Box>
{resource.loading ? <Stack direction={{ xs: "column", lg: "row" }} spacing={2}>{[0, 1].map((n) => <Skeleton key={n} variant="rounded" height={360} sx={{ flex: 1 }} />)}</Stack> : null}
{resource.error ? <Alert severity="error" action={<Button color="inherit" onClick={() => void resource.reload()}>{t("retry")}</Button>}>{resource.error.message}</Alert> : null}
{!resource.loading && !resource.error && resource.data.items.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>{t("offerComparisonEmpty")}</Typography>
<Stack spacing={2} sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("offerComparisonTitle")} description={t("offerComparisonHelp")}
actions={!loading && !resource.error && resource.data.items.length > 0 ? <Button variant="outlined" onClick={() => navigate("/jobs?status=Offer&sortBy=dateApplied&sortDir=asc")}>{t("offerComparisonAllOffers")}</Button> : null} />
<Box ref={resultsRef} role="region" aria-label={t("offerComparisonTitle")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
<ViewStateNotice compact loading={loading} title={t("offerComparisonLoadFailed")}
error={resource.error ? { ...resource.error, message: resource.error.message || t("offerComparisonLoadFailed") } : null}
onRetry={() => {
if (loading || retryPending.current) return;
retryPending.current = true;
resultsRef.current?.focus();
void resource.reload().finally(() => { retryPending.current = false; });
}} />
{!loading && !resource.error && resource.data.items.length === 0 ? (
<Paper variant="outlined" sx={{ p: "var(--app-card-padding)", textAlign: "center" }}>
<Typography component="h3" variant="sectionTitle">{t("offerComparisonEmpty")}</Typography>
<Typography color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>{t("offerComparisonEmptyHelp")}</Typography>
<Button variant="contained" onClick={() => navigate("/jobs")}>{t("jobApplications")}</Button>
</Paper>
) : null}
{!loading && !resource.error && resource.data.items.length > 0 ? <Box sx={{ mb: 2 }}>
<Typography variant="body2">{t("offerComparisonSampleCount", { shown: resource.data.items.length, total: resource.data.total })}</Typography>
{resource.data.total > resource.data.items.length ? <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, overflowWrap: "anywhere" }}>{t("offerComparisonSampleHelp")}</Typography> : null}
</Box> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))", xl: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{resource.data.items.map((offer) => (
<Paper key={offer.id} variant="outlined" sx={{ p: 2.5, display: "flex", flexDirection: "column", minWidth: 0 }}>
<Paper key={offer.id} component="article" aria-label={offer.jobTitle} variant="outlined" sx={{ p: "var(--app-card-padding)", display: "flex", flexDirection: "column", minWidth: 0 }}>
<Stack direction="row" justifyContent="space-between" spacing={1} alignItems="flex-start">
<Box sx={{ minWidth: 0 }}><Typography variant="h6" sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{offer.jobTitle}</Typography><Typography color="text.secondary">{offer.company?.name}</Typography></Box>
<Chip size="small" color="success" label={t("statusOffer")} />
<Box sx={{ minWidth: 0 }}><Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{offer.jobTitle}</Typography><Typography color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{offer.company?.name}</Typography></Box>
<Chip size="small" color="success" label={t("statusOffer")} sx={{ flexShrink: 0 }} />
</Stack>
<Stack spacing={1.25} sx={{ my: 2 }}>
<Fact label={t("offerComparisonCompensation")} value={formatSalary(offer, t("offerComparisonNotRecorded"))} />
<Fact label={t("offerComparisonCompensation")} value={formatSalary(offer, t("offerComparisonNotRecorded"), locale, periods)} />
<Fact label={t("companiesLocation")} value={offer.location || t("offerComparisonNotRecorded")} />
<Fact label={t("jobTableDateApplied")} value={date(offer.dateApplied)} />
<Fact label={t("offerComparisonDecisionDeadline")} value={date(offer.deadline)} />
<Fact label={t("companiesNextContact")} value={date(offer.followUpAt)} />
<Fact label={t("workspaceNextAction")} value={offer.nextAction || t("offerComparisonNotRecorded")} />
<Fact label={t("offerComparisonNotes")} value={offer.notes || t("offerComparisonNotRecorded")} multiline />
</Stack>
<Button variant="outlined" sx={{ mt: "auto", alignSelf: "flex-start" }} onClick={() => navigate(`/jobs/${offer.id}`)}>{t("workspaceOpenFull")}</Button>
<Button variant="outlined" sx={{ mt: "auto", alignSelf: "flex-start", maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(`/jobs/${offer.id}`)}>{t("workspaceOpenFull")}</Button>
</Paper>
))}
</Box>
</Box>
</Stack>
);
}
@@ -57,10 +75,11 @@ function Fact({ label, value, multiline = false }: { label: string; value: strin
return <Box><Typography variant="overline" color="text.secondary">{label}</Typography><Typography variant="body2" sx={{ whiteSpace: multiline ? "pre-wrap" : "normal", overflowWrap: "anywhere" }}>{value}</Typography></Box>;
}
function formatSalary(job: JobApplication, fallback: string) {
function formatSalary(job: JobApplication, fallback: string, locale: string, periods: Map<string, string>) {
if (job.salaryMin != null || job.salaryMax != null) {
const range = [job.salaryMin, job.salaryMax].filter((value) => value != null).map((value) => Number(value).toLocaleString()).join(" – ");
return `${range} ${job.salaryCurrency || ""}${job.salaryPeriod ? ` / ${job.salaryPeriod}` : ""}`.trim();
const range = [job.salaryMin, job.salaryMax].filter((value) => value != null).map((value) => Number(value).toLocaleString(locale)).join(" – ");
const period = job.salaryPeriod ? periods.get(job.salaryPeriod.toLowerCase()) ?? job.salaryPeriod : null;
return `${range} ${job.salaryCurrency || ""}${period ? ` / ${period}` : ""}`.trim();
}
return job.salary || fallback;
}