feat(jobs): add dedicated workspace page
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ export default function CorrespondenceInboxPage() {
|
||||
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
||||
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? "Hide message" : "View message"}</Button>
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>Open job</Button>
|
||||
{item.provider === "gmail" && item.externalThreadId ? (
|
||||
<Button size="small" color="warning" variant="text" disabled={unlinkingThreadId === item.externalThreadId} onClick={() => void unlinkGmailThread(item)}>
|
||||
{unlinkingThreadId === item.externalThreadId ? "Unlinking…" : "Unlink thread"}
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
});
|
||||
await load();
|
||||
toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
||||
navigate(`/jobs?open=${res.data.jobApplicationId}`);
|
||||
navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error");
|
||||
} finally {
|
||||
@@ -216,7 +216,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
/>
|
||||
))}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
|
||||
Open top job
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user