feat(ui): redesign job header and overview

This commit is contained in:
cesnimda
2026-10-06 22:58:45 +02:00
parent 42a6e425b6
commit fadf99a3f5
41 changed files with 3779 additions and 67 deletions
@@ -4,7 +4,7 @@ import {
} from "react-router-dom";
import {
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, Divider, IconButton, Paper,
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, IconButton, Paper,
Skeleton, Stack, Tab, Tabs, Tooltip, Typography,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -31,6 +31,9 @@ import {
} from "../components/ApplicationAssets";
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import EditJobDialog from "../components/EditJobDialog";
import ViewStateNotice from "../components/ViewStateNotice";
import { PageHeader, PageSection } from "../layout/PageLayout";
import type { ViewResourceError } from "../hooks/useViewResource";
import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist";
import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../pipeline";
import { useI18n } from "../i18n/I18nProvider";
@@ -74,7 +77,8 @@ export function ApplicationWorkspace({
const section = sectionOverride ?? workspaceSection(params.get("section"));
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ViewResourceError | null>(null);
const [loading, setLoading] = useState(true);
const [editOpen, setEditOpen] = useState(false);
const [coverLetterDirty, setCoverLetterDirty] = useState(false);
const [packageDraftsDirty, setPackageDraftsDirty] = useState(false);
@@ -115,14 +119,19 @@ export function ApplicationWorkspace({
const load = useCallback(async () => {
if (!Number.isInteger(jobId) || jobId <= 0) {
setOverview(null);
setError(t("workspaceInvalidLink"));
setError({ kind: "error", message: t("workspaceInvalidLink"), retryable: false });
setLoading(false);
return;
}
try {
setError(null);
setLoading(true);
setOverview(await applicationWorkspaceApi.overview(jobId));
} catch (err) {
setError(getApiErrorMessage(err, t("workspaceLoadFailed")));
const status = (err as { response?: { status?: number } })?.response?.status;
setError({ kind: "error", message: getApiErrorMessage(err, t("workspaceLoadFailed")), retryable: !status || status >= 500, status });
} finally {
setLoading(false);
}
}, [jobId, t]);
@@ -146,16 +155,17 @@ export function ApplicationWorkspace({
if (error) {
return (
<Box sx={{ p: 3 }}>
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
<Button startIcon={<ArrowBackIcon />} onClick={close}>{t("workspaceBack")}</Button>
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
<ViewStateNotice title={t("workspaceLoadFailed")} error={error} onRetry={load} compact />
</Box>
);
}
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<ViewStateNotice title={t("workspaceLoadFailed")} loading={loading && !overview} compact />
<Paper variant="outlined" sx={{ minWidth: 0, borderRadius: 1 }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
<Tooltip title={t("workspaceBack")}>
<IconButton size="small" aria-label={t("workspaceBack")} onClick={close}>
@@ -229,34 +239,19 @@ export function ApplicationWorkspace({
);
}
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
const { t } = useI18n();
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
const { language, t } = useI18n();
if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>;
return (
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2.5 }}>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
<Typography color="text.secondary">
{[overview.company, overview.location, overview.salary].filter(Boolean).join(" · ") || "—"}
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title={t("workspaceEditApplication")}>
<IconButton size="small" aria-label={t("workspaceEditApplication")} onClick={onEdit}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
{overview.jobUrl && (
<Tooltip title={t("workspaceOpenAdvert")}>
<IconButton size="small" aria-label={t("workspaceOpenAdvert")} href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</Stack>
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2 }}>
<PageHeader level="h2" title={overview.jobTitle} description={overview.company ?? undefined} actions={<>
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEditApplication")}</Button>
{overview.jobUrl ? <Button component="a" href={overview.jobUrl} target="_blank" rel="noopener noreferrer" endIcon={<OpenInNewIcon />}>{t("workspaceOpenAdvert")}</Button> : null}
</>} />
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, mt: 1.5, minWidth: 0 }}>
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" sx={{ maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }} />
{overview.location ? <Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{overview.location}</Typography> : null}
{overview.deadline ? <Typography variant="body2" color="text.secondary">{t("jobDetailsDeadline")}: {new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography> : null}
</Box>
<ApplicationProgress status={overview.status} />
</Box>
);
@@ -265,18 +260,19 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
function ApplicationProgress({ status }: { status: string }) {
const { t } = useI18n();
const normalized = normalizeStatus(status);
const currentOrder = normalized === "Other" ? Number.MAX_SAFE_INTEGER : PIPELINE_STATUSES.indexOf(normalized) + 1;
const currentOrder = normalized === "Other" ? 0 : PIPELINE_STATUSES.indexOf(normalized) + 1;
const isTerminal = normalized === "Rejected" || normalized === "Ghosted" || normalized === "Withdrawn";
const stages = isTerminal
const stages = normalized === "Other" ? [] : isTerminal
? [...PIPELINE_STATUSES.filter((stage) => !["Offer", "Rejected", "Ghosted", "Withdrawn"].includes(stage)), normalized]
: PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage));
return (
<Box sx={{ mt: 2.5 }} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
{t("workspaceProgress")}
</Typography>
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
<Box role="region" tabIndex={0} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`} sx={{ display: "flex", overflowX: "auto", p: 1, mt: 0.5, borderRadius: 1 }}>
{normalized === "Other" ? <Typography variant="body2" color="text.secondary">{t("workspaceCustomProgress")}</Typography> : null}
{stages.map((stage, index) => {
const order = PIPELINE_STATUSES.indexOf(stage) + 1;
const current = stage === normalized;
@@ -284,7 +280,7 @@ function ApplicationProgress({ status }: { status: string }) {
return (
<Box key={stage} sx={{ display: "grid", gridTemplateColumns: index ? "minmax(30px, 1fr) auto" : "auto", alignItems: "center", minWidth: index ? 110 : 72, flex: 1 }}>
{index > 0 && <Box aria-hidden sx={{ height: 2, bgcolor: complete || current ? "primary.main" : "divider" }} />}
<Stack alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
<Stack aria-current={current ? "step" : undefined} alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
<Box aria-hidden sx={{ width: 12, height: 12, borderRadius: "50%", border: 2, borderColor: current || complete ? "primary.main" : "divider", bgcolor: complete ? "primary.main" : "background.paper", boxShadow: current ? (theme) => `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} />
<Typography variant="caption" sx={{ whiteSpace: "nowrap", fontWeight: current ? 800 : 600, color: current ? "text.primary" : "text.secondary" }}>
{statusLabel(t, stage)}
@@ -326,57 +322,65 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
: null;
return (
<Stack spacing={2}>
<Stack spacing={2} sx={{ minWidth: 0 }}>
<PageSection surface title={t("workspaceApplicationInfo")}>
<Box component="dl" sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 2 }}>
{[
[t("workspaceDiscovered"), overview.savedAt],
[t("workspaceApplied"), overview.dateApplied],
[t("jobDetailsDeadline"), overview.deadline],
[t("intelligenceCategoryFollowUp"), overview.followUpAt],
].map(([label, date]) => <Box key={label}>
<Typography component="dt" variant="caption" color="text.secondary">{label}</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0, mt: 0.5, fontWeight: 600 }}>{date ? new Date(date).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"}</Typography>
</Box>)}
</Box>
</PageSection>
{overview.nextStep ? (
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
<Typography variant="overline" color="text.secondary">{t("workspaceNextAction")}</Typography>
<Typography variant="h6" sx={{ fontWeight: 800 }}>{nextStepLabel}</Typography>
<PageSection surface title={t("workspaceNextAction")}>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography>
<Button variant="contained" endIcon={<ArrowForwardIcon />}
onClick={() => overview.nextStep?.section && onGo(overview.nextStep.section as WorkspaceSectionKey)}>
{nextStepLabel}
</Button>
</Paper>
</PageSection>
) : (
<Alert severity="success" sx={{ borderRadius: 3 }}>
{t("workspaceNothingOutstanding")}
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1.5 }}>
<PageSection title={t("workspacePreparation")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1 }}>
{stats.map((s) => (
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
<Button key={s.label} variant="outlined"
onClick={() => onGo(s.go)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onGo(s.go); } }}
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", "&:focus-visible": { boxShadow: 3 } }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.disabled" }}>
sx={{ p: 1.5, display: "block", textAlign: "left", color: "text.primary", borderColor: "divider", minWidth: 0, whiteSpace: "normal", overflowWrap: "anywhere" }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.secondary" }}>
{s.icon}
<Typography variant="caption" sx={{ fontWeight: 700 }}>{s.label}</Typography>
</Stack>
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>{s.value}</Typography>
</Paper>
</Button>
))}
</Box>
</PageSection>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceRecentActivity")}</Typography>
<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>
</Stack>
<Divider sx={{ my: 1 }} />
<PageSection surface title={t("workspaceRecentActivity")} actions={<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>}>
{overview.recentActivity.length === 0 ? (
<Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography>
) : (
<Stack spacing={0.75}>
{overview.recentActivity.map((a, i) => (
<Stack key={i} direction="row" spacing={1} justifyContent="space-between">
<Typography variant="body2"><strong>{a.type}</strong>{a.detail ? ` — ${a.detail}` : ""}</Typography>
<Typography variant="caption" color="text.secondary">{new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography>
<Stack key={i} direction={{ xs: "column", sm: "row" }} spacing={0.5} justifyContent="space-between">
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}><strong>{a.type}</strong>{a.detail ? ` — ${a.detail}` : ""}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography>
</Stack>
))}
</Stack>
)}
</Paper>
</PageSection>
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
</Stack>