feat(jobs): add dedicated workspace page
CI and Deploy / test (pull_request) Failing after 2m51s
CI and Deploy / deploy (pull_request) Has been skipped

Make /jobs/:id the canonical application workspace while preserving list state and compatibility links. Replace popup and expandable-row navigation with accessible whole-row routing and richer job details.
This commit is contained in:
cesnimda
2026-08-15 13:33:00 +02:00
parent 0dfaac18a1
commit 109745edb0
18 changed files with 310 additions and 266 deletions
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
@@ -13,6 +13,7 @@ import MailOutlineIcon from "@mui/icons-material/MailOutline";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import ChecklistIcon from "@mui/icons-material/Checklist";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { getApiErrorMessage } from "../api";
import Attachments from "../components/Attachments";
@@ -26,6 +27,7 @@ import {
ApplicationCoverLetterSection, ApplicationCvSection,
} from "../components/ApplicationAssets";
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import EditJobDialog from "../components/EditJobDialog";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection,
} from "../applicationWorkspace";
@@ -56,15 +58,23 @@ export function ApplicationWorkspace({
}: ApplicationWorkspaceProps) {
const { id } = useParams();
const jobId = jobIdOverride ?? Number(id);
const location = useLocation();
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const section = sectionOverride ?? workspaceSection(params.get("section"));
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const load = useCallback(async () => {
if (!Number.isInteger(jobId) || jobId <= 0) {
setOverview(null);
setError("This application link is invalid.");
return;
}
try {
setError(null);
setOverview(await applicationWorkspaceApi.overview(jobId));
} catch (err) {
setError(getApiErrorMessage(err, "Could not open this application."));
@@ -77,9 +87,12 @@ export function ApplicationWorkspace({
const go = (next: WorkspaceSectionKey) => {
if (onSectionChange) onSectionChange(next);
else setParams({ section: next }, { replace: true });
else setParams({ section: next }, { replace: true, state: location.state });
};
const close = onClose ?? (() => navigate("/jobs"));
const close = onClose ?? (() => {
const from = (location.state as { from?: unknown } | null)?.from;
navigate(typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs", { replace: true });
});
if (error) {
return (
@@ -131,9 +144,9 @@ export function ApplicationWorkspace({
</Paper>
<Box sx={{ display: "grid", gap: 2 }}>
<WorkspaceHeader overview={overview} />
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />}
{section === "job-details" && <JobDetailsSection overview={overview} />}
{section === "job-details" && <JobDetailsSection overview={overview} onEdit={() => setEditOpen(true)} />}
{/* Deterministic answer first, then the AI panel below it — the page never generates on load. */}
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />}
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
@@ -164,10 +177,16 @@ export function ApplicationWorkspace({
</>
)}
</Box>
<EditJobDialog
open={editOpen}
jobId={jobId > 0 ? jobId : null}
onClose={() => setEditOpen(false)}
onSaved={() => { setEditOpen(false); void load(); }}
/>
</Box>
);
}
function WorkspaceHeader({ overview }: { overview: WorkspaceOverview | null }) {
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
return (
<Paper sx={{ p: 2.5, borderRadius: 3 }}>
@@ -179,8 +198,14 @@ function WorkspaceHeader({ overview }: { overview: WorkspaceOverview | null }) {
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title="Edit application">
<IconButton size="small" aria-label="Edit application" onClick={onEdit}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Chip size="small" label={overview.status} color="primary" variant="outlined" />
<Chip size="small" label={overview.stageGroup} />
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
{overview.jobUrl && (
<Tooltip title="Open original advert">
<IconButton size="small" aria-label="Open original advert" href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
@@ -267,34 +292,72 @@ function OverviewSection({ overview, onGo, onReload }: {
);
}
function JobDetailsSection({ overview }: { overview: WorkspaceOverview | null }) {
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
if (!overview) return <Skeleton variant="rounded" height={200} />;
const rows: [string, string][] = [
["Company", overview.company ?? "—"],
["Location", overview.location ?? "—"],
["Country", overview.countryCode ?? "—"],
["Source", overview.source ?? "—"],
["Salary", overview.salary ?? "—"],
["Status", overview.status],
["Discovered", overview.savedAt ? new Date(overview.savedAt).toLocaleDateString() : "—"],
["Applied", overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString() : "—"],
["Deadline", overview.deadline ? new Date(overview.deadline).toLocaleDateString() : "—"],
["Follow-up", overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString() : "Not scheduled"],
["Next action", overview.nextAction ?? "—"],
];
return (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Job details</Typography>
{!overview.hasJobDescription && (
<Alert severity="warning" sx={{ mb: 1.5, borderRadius: 2 }}>
No advert text saved. Analysis and matching need it add it from the application dialog.
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "160px 1fr" }, rowGap: 0.75, columnGap: 2 }}>
{rows.map(([k, v]) => (
<React.Fragment key={k}>
<Typography variant="body2" color="text.secondary">{k}</Typography>
<Typography variant="body2">{v}</Typography>
</React.Fragment>
))}
</Box>
</Paper>
<Stack spacing={2}>
<Paper sx={{ p: 2.5, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>Application information</Typography>
<Button size="small" startIcon={<EditOutlinedIcon />} onClick={onEdit}>Edit</Button>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
{rows.map(([k, v]) => (
<Box key={k} sx={{ minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>{k}</Typography>
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{v}</Typography>
</Box>
))}
</Box>
{overview.tags.length > 0 ? (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, mt: 2 }}>
{overview.tags.map((tag) => <Chip key={tag} size="small" label={tag} />)}
</Box>
) : null}
{overview.notes ? (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>Notes</Typography>
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview.notes}</Typography>
</Box>
) : null}
</Paper>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1.5 }}>Job description</Typography>
{!overview.hasJobDescription ? (
<Alert severity="warning" sx={{ borderRadius: 2 }} action={<Button color="inherit" size="small" onClick={onEdit}>Add advert</Button>}>
No advert text saved. Analysis and matching need the job description.
</Alert>
) : (
<Stack spacing={2.5}>
{overview.translatedDescription ? (
<Box>
<Typography variant="overline" color="text.secondary">Translated advert</Typography>
<Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.translatedDescription}</Typography>
</Box>
) : null}
{overview.description ? (
<Box>
{overview.translatedDescription ? <Typography variant="overline" color="text.secondary">Original advert{overview.descriptionLanguage ? ` · ${overview.descriptionLanguage.toUpperCase()}` : ""}</Typography> : null}
<Typography sx={{ mt: 0.5, whiteSpace: "pre-wrap", overflowWrap: "anywhere", lineHeight: 1.7 }}>{overview.description}</Typography>
</Box>
) : null}
</Stack>
)}
</Paper>
</Stack>
);
}