From dbb9abb731f1b05dad445b44951ef8ca70d315b0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 17:28:49 +0200 Subject: [PATCH] feat(offers): add comparison workspace --- docs/work-programmes/master-progress.md | 1 + job-tracker-ui/src/App.tsx | 7 ++ job-tracker-ui/src/i18n/translations.ts | 22 +++++++ job-tracker-ui/src/offer-comparison.test.tsx | 34 ++++++++++ .../src/views/OfferComparisonPage.tsx | 66 +++++++++++++++++++ 5 files changed, 130 insertions(+) create mode 100644 job-tracker-ui/src/offer-comparison.test.tsx create mode 100644 job-tracker-ui/src/views/OfferComparisonPage.tsx diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 048582c..4359c24 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -112,6 +112,7 @@ Updated: 2026-08-31 - Saved job-search implementation: tenant-owned NAV search definitions now retain per-vacancy first-seen, last-seen and dismissal state; repeat runs identify only genuinely new results, while one-off discovery remains unchanged. Account export/deletion and provider-safe schema ownership include the new records. Focused backend tests passed 3/3, focused frontend tests passed 6/6, schema/migration gates passed 20/20, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed. - Interview debrief loop: interview-stage workspaces now create an explicit, durable debrief in the existing user-owned preparation model, prompting for questions, strengths, improvements, feedback and next steps without generating answers. It remains editable/deletable and is localized in English and Bokmål. Focused backend tests passed 11/11, focused frontend tests passed 13/13, ESLint and the optimized Next build/TypeScript passed. - Recruiter relationship view: each company now exposes a tenant-scoped view over its existing recruiter details, active applications, status, message totals and recent correspondence, with direct application navigation and no duplicate contact store. Focused backend authorization/data tests passed 2/2, the frontend interaction test passed 1/1, ESLint and the optimized Next build/TypeScript passed. +- Offer comparison workspace: active applications in the existing Offer status now appear in a responsive side-by-side comparison of recorded compensation, location, decision deadline, next contact, next action and user notes. Missing facts remain explicitly unknown; no parallel offer record was introduced. Focused frontend tests passed 2/2, ESLint and the optimized Next build/TypeScript passed. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index b797fb9..55ada62 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -16,6 +16,7 @@ import SearchIcon from "@mui/icons-material/Search"; import MemoryIcon from "@mui/icons-material/Memory"; import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; +import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; import { Navigate, Route, Routes, useLocation, useNavigate, useParams, createBrowserRouter, RouterProvider } from "react-router-dom"; @@ -62,6 +63,7 @@ const AdminUsersPage = lazy(() => import("./views/AdminUsersPage")); const AdminSystemPage = lazy(() => import("./views/AdminSystemPage")); const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage")); const OperationsPage = lazy(() => import("./views/OperationsPage")); +const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage")); const NotFoundPage = lazy(() => import("./views/NotFoundPage")); type AuthConfig = { requireAuth: boolean }; @@ -93,6 +95,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] { if (path.startsWith("/operations")) return [t("home"), t("operations")]; if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")]; if (path.startsWith("/companies")) return [t("home"), t("companies")]; + if (path.startsWith("/offers")) return [t("home"), t("offerComparisonTitle")]; if (path.startsWith("/correspondence/review")) return [t("home"), t("gmailReviewQueue")]; if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")]; if (path.startsWith("/trash")) return [t("home"), t("trash")]; @@ -116,6 +119,7 @@ function titleFor(path: string, t: (k: any) => string): string { if (path.startsWith("/jobs")) return t("jobApplications"); if (path.startsWith("/kanban")) return t("kanbanBoard"); if (path.startsWith("/companies")) return t("companies"); + if (path.startsWith("/offers")) return t("offerComparisonTitle"); if (path.startsWith("/correspondence/review")) return t("gmailReviewQueue"); if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle"); if (path.startsWith("/trash")) return t("trash"); @@ -137,6 +141,7 @@ function subtitleFor(path: string, t: (k: any) => string): string | undefined { if (path.startsWith("/jobs")) return t("jobsPageSubtitle"); if (path.startsWith("/kanban")) return t("kanbanPageSubtitle"); if (path.startsWith("/reminders")) return t("remindersPageSubtitle"); + if (path.startsWith("/offers")) return t("offerComparisonPageSubtitle"); if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle"); if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle"); return undefined; @@ -287,6 +292,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo { to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") }, { to: "/companies", label: t("companies"), icon: , section: t("manage") }, + { to: "/offers", label: t("offerComparisonNav"), icon: , section: t("manage") }, { to: "/correspondence", label: t("correspondenceInbox"), icon: , section: t("manage") }, { to: "/career", label: t("careerWorkspace"), icon: , section: t("manage") }, { to: "/career/builder", label: t("cvBuilder"), icon: , section: t("manage") }, @@ -376,6 +382,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> } /> + } /> } /> } /> } /> diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index c1feaa5..b789106 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1439,6 +1439,17 @@ export const translations = { companiesRecentContact: "Recent contact", companiesNoContact: "No correspondence has been recorded for this company.", companiesContact: "Contact", + 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.", + 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", + offerComparisonNotes: "Notes and considerations", + offerComparisonNotRecorded: "Not recorded", adminUsersTitle: "Users", adminUsersSubtitle: "Admin-only user management.", adminUsersCreateUser: "Create user", @@ -3828,6 +3839,17 @@ export const translations = { companiesRecentContact: "Nylig kontakt", companiesNoContact: "Ingen korrespondanse er registrert for dette selskapet.", companiesContact: "Kontakt", + 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.", + 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", + offerComparisonNotes: "Notater og vurderinger", + offerComparisonNotRecorded: "Ikke registrert", adminUsersTitle: "Brukere", adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.", adminUsersCreateUser: "Opprett bruker", diff --git a/job-tracker-ui/src/offer-comparison.test.tsx b/job-tracker-ui/src/offer-comparison.test.tsx new file mode 100644 index 0000000..2dab04a --- /dev/null +++ b/job-tracker-ui/src/offer-comparison.test.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; +import OfferComparisonPage from "./views/OfferComparisonPage"; + +jest.mock("./api", () => ({ + api: { get: jest.fn() }, + getApiErrorMessage: (_error: unknown, fallback: string) => fallback, +})); +const mockedApi = api as jest.Mocked; + +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(); + + expect(await screen.findByText("Backend Engineer")).toBeInTheDocument(); + expect(screen.getByText("800,000 – 900,000 NOK / year")).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" } }); +}); + +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(); + expect(await screen.findByText("No active offers to compare")).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/OfferComparisonPage.tsx b/job-tracker-ui/src/views/OfferComparisonPage.tsx new file mode 100644 index 0000000..d5f2d15 --- /dev/null +++ b/job-tracker-ui/src/views/OfferComparisonPage.tsx @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { Alert, Box, Button, Chip, Paper, Skeleton, 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 type { JobApplication } from "../types"; + +type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSize: number }; + +export default function OfferComparisonPage() { + const navigate = useNavigate(); + const { language, t } = useI18n(); + const load = useCallback(() => api.get("/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") : "—"; + + return ( + + + {t("offerComparisonTitle")} + {t("offerComparisonHelp")} + + {resource.loading ? {[0, 1].map((n) => )} : null} + {resource.error ? void resource.reload()}>{t("retry")}}>{resource.error.message} : null} + {!resource.loading && !resource.error && resource.data.items.length === 0 ? ( + + {t("offerComparisonEmpty")} + {t("offerComparisonEmptyHelp")} + + + ) : null} + + {resource.data.items.map((offer) => ( + + + {offer.jobTitle}{offer.company?.name} + + + + + + + + + + + + + ))} + + + ); +} + +function Fact({ label, value, multiline = false }: { label: string; value: string; multiline?: boolean }) { + return {label}{value}; +} + +function formatSalary(job: JobApplication, fallback: 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(); + } + return job.salary || fallback; +}