feat(offers): add comparison workspace
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user