feat(offers): add comparison workspace

This commit is contained in:
cesnimda
2026-08-31 17:28:49 +02:00
parent f3569d05a9
commit dbb9abb731
5 changed files with 130 additions and 0 deletions
+1
View File
@@ -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. - 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. - 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. - 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. - Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed. - Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed. - Next production build and TypeScript: passed.
+7
View File
@@ -16,6 +16,7 @@ import SearchIcon from "@mui/icons-material/Search";
import MemoryIcon from "@mui/icons-material/Memory"; import MemoryIcon from "@mui/icons-material/Memory";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MailOutlineIcon from "@mui/icons-material/MailOutline"; 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"; 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 AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage")); const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const OperationsPage = lazy(() => import("./views/OperationsPage")); const OperationsPage = lazy(() => import("./views/OperationsPage"));
const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage")); const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean }; 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("/operations")) return [t("home"), t("operations")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")]; if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")]; 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/review")) return [t("home"), t("gmailReviewQueue")];
if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")]; if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")];
if (path.startsWith("/trash")) return [t("home"), t("trash")]; 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("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard"); if (path.startsWith("/kanban")) return t("kanbanBoard");
if (path.startsWith("/companies")) return t("companies"); 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/review")) return t("gmailReviewQueue");
if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle"); if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle");
if (path.startsWith("/trash")) return t("trash"); 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("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle"); if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle"); 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/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle"); if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
return undefined; return undefined;
@@ -287,6 +292,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") }, { to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") }, { to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/offers", label: t("offerComparisonNav"), icon: <CompareArrowsIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("manage") }, { to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/career", label: t("careerWorkspace"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") }, { to: "/career", label: t("careerWorkspace"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") }, { to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
@@ -376,6 +382,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/operations" element={<OperationsPage />} /> <Route path="/operations" element={<OperationsPage />} />
<Route path="/kanban" element={<KanbanBoard />} /> <Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} /> <Route path="/companies" element={<CompaniesTable />} />
<Route path="/offers" element={<OfferComparisonPage />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} /> <Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} /> <Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
+22
View File
@@ -1439,6 +1439,17 @@ export const translations = {
companiesRecentContact: "Recent contact", companiesRecentContact: "Recent contact",
companiesNoContact: "No correspondence has been recorded for this company.", companiesNoContact: "No correspondence has been recorded for this company.",
companiesContact: "Contact", 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", adminUsersTitle: "Users",
adminUsersSubtitle: "Admin-only user management.", adminUsersSubtitle: "Admin-only user management.",
adminUsersCreateUser: "Create user", adminUsersCreateUser: "Create user",
@@ -3828,6 +3839,17 @@ export const translations = {
companiesRecentContact: "Nylig kontakt", companiesRecentContact: "Nylig kontakt",
companiesNoContact: "Ingen korrespondanse er registrert for dette selskapet.", companiesNoContact: "Ingen korrespondanse er registrert for dette selskapet.",
companiesContact: "Kontakt", 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", adminUsersTitle: "Brukere",
adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.", adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.",
adminUsersCreateUser: "Opprett bruker", adminUsersCreateUser: "Opprett bruker",
@@ -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<typeof api>;
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>);
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(<I18nProvider><MemoryRouter><OfferComparisonPage /></MemoryRouter></I18nProvider>);
expect(await screen.findByText("No active offers to compare")).toBeInTheDocument();
});
@@ -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<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") : "—";
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>
<Typography color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>{t("offerComparisonEmptyHelp")}</Typography>
<Button variant="contained" onClick={() => navigate("/jobs")}>{t("jobApplications")}</Button>
</Paper>
) : 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 }}>
<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")} />
</Stack>
<Stack spacing={1.25} sx={{ my: 2 }}>
<Fact label={t("offerComparisonCompensation")} value={formatSalary(offer, t("offerComparisonNotRecorded"))} />
<Fact label={t("companiesLocation")} value={offer.location || t("offerComparisonNotRecorded")} />
<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>
</Paper>
))}
</Box>
</Stack>
);
}
function Fact({ label, value, multiline = false }: { label: string; value: string; multiline?: boolean }) {
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) {
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;
}