feat(ui): migrate recorded offer comparison
This commit is contained in:
@@ -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