feat(ui): redesign job header and overview
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user