feat(ui): migrate recorded offer comparison
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user