Files
jobtrackingapp/job-tracker-ui/src/views/OfferComparisonPage.tsx
T
2026-10-13 13:19:53 +02:00

86 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
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: "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} 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} 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 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"), 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", maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(`/jobs/${offer.id}`)}>{t("workspaceOpenFull")}</Button>
</Paper>
))}
</Box>
</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, 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(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;
}