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
@@ -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;
}