109745edb0
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.
364 lines
17 KiB
TypeScript
364 lines
17 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
|
|
import {
|
|
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
|
|
Skeleton, Stack, Tooltip, Typography,
|
|
} from "@mui/material";
|
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
|
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
|
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";
|
|
import Correspondence from "../components/Correspondence";
|
|
import AiWorkspacePanel from "../components/AiWorkspacePanel";
|
|
import ApplicationChecklist from "../components/ApplicationChecklist";
|
|
import {
|
|
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
|
} from "../components/ApplicationIntelligence";
|
|
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";
|
|
|
|
// Phase 5 Milestone 1 — the dedicated Application Workspace.
|
|
//
|
|
// This is a surface, not a new data store: the overview is one aggregate read, and each section
|
|
// reuses the component that already owns that domain (Attachments, Correspondence, AiWorkspacePanel).
|
|
// docs/architecture/application-workspace.md.
|
|
export default function ApplicationWorkspacePage() {
|
|
return <ApplicationWorkspace />;
|
|
}
|
|
|
|
type ApplicationWorkspaceProps = {
|
|
jobIdOverride?: number;
|
|
sectionOverride?: WorkspaceSectionKey;
|
|
onSectionChange?: (section: WorkspaceSectionKey) => void;
|
|
onClose?: () => void;
|
|
fullPageHref?: string;
|
|
};
|
|
|
|
export function ApplicationWorkspace({
|
|
jobIdOverride,
|
|
sectionOverride,
|
|
onSectionChange,
|
|
onClose,
|
|
fullPageHref,
|
|
}: 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."));
|
|
}
|
|
}, [jobId]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const go = (next: WorkspaceSectionKey) => {
|
|
if (onSectionChange) onSectionChange(next);
|
|
else setParams({ section: next }, { replace: true, state: location.state });
|
|
};
|
|
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 (
|
|
<Box sx={{ p: 3 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={close}>Back to applications</Button>
|
|
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "220px 1fr" }, gap: 2, alignItems: "start" }}>
|
|
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}>
|
|
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}>
|
|
<Tooltip title="Back to applications">
|
|
<IconButton size="small" aria-label="Back to applications" onClick={close}>
|
|
<ArrowBackIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
|
|
Workspace
|
|
</Typography>
|
|
{fullPageHref ? (
|
|
<Tooltip title="Open full-page workspace">
|
|
<IconButton
|
|
component="a"
|
|
href={fullPageHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
size="small"
|
|
aria-label="Open full-page workspace"
|
|
sx={{ ml: "auto" }}
|
|
>
|
|
<OpenInNewIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
) : null}
|
|
</Stack>
|
|
<List dense component="nav" aria-label="Workspace sections">
|
|
{WORKSPACE_SECTIONS.map((s) => (
|
|
<ListItemButton key={s.key} selected={section === s.key} onClick={() => go(s.key)} sx={{ borderRadius: 2 }}>
|
|
<ListItemText
|
|
primary={s.label}
|
|
slotProps={{ primary: { fontSize: 14, fontWeight: section === s.key ? 700 : 500 } }}
|
|
/>
|
|
</ListItemButton>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
|
|
<Box sx={{ display: "grid", gap: 2 }}>
|
|
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
|
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />}
|
|
{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} />}
|
|
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
|
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
|
|
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
|
|
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
|
<AiWorkspacePanel jobId={jobId} />
|
|
</Paper>
|
|
)}
|
|
{section === "documents" && jobId > 0 && (
|
|
<Paper sx={{ p: 2, borderRadius: 3 }}><Attachments jobId={jobId} /></Paper>
|
|
)}
|
|
{section === "communication" && jobId > 0 && (
|
|
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
|
<Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} />
|
|
</Paper>
|
|
)}
|
|
{section === "checklist" && jobId > 0 && (
|
|
<ApplicationChecklist jobId={jobId} onChanged={load} />
|
|
)}
|
|
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
|
|
{section === "cover-letter" && jobId > 0 && (
|
|
<>
|
|
<ApplicationCoverLetterSection jobId={jobId} />
|
|
{/* Generation stays an explicit user action, below the editor the user owns. */}
|
|
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
|
|
</>
|
|
)}
|
|
</Box>
|
|
<EditJobDialog
|
|
open={editOpen}
|
|
jobId={jobId > 0 ? jobId : null}
|
|
onClose={() => setEditOpen(false)}
|
|
onSaved={() => { setEditOpen(false); void load(); }}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
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 }}>
|
|
<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="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">
|
|
<OpenInNewIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function OverviewSection({ overview, onGo, onReload }: {
|
|
overview: WorkspaceOverview | null;
|
|
onGo: (s: WorkspaceSectionKey) => void;
|
|
onReload: () => void;
|
|
}) {
|
|
const stats = useMemo(() => overview ? [
|
|
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
|
|
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
|
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const },
|
|
{ icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const },
|
|
{ icon: <ChecklistIcon fontSize="small" />, label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const },
|
|
] : [], [overview]);
|
|
|
|
if (!overview) {
|
|
return <Stack spacing={2}>{[0, 1].map((i) => <Skeleton key={i} variant="rounded" height={120} />)}</Stack>;
|
|
}
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
{overview.nextStep ? (
|
|
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
|
|
<Typography variant="overline" color="text.secondary">Next recommended action</Typography>
|
|
<Typography variant="h6" sx={{ fontWeight: 800 }}>{overview.nextStep.label}</Typography>
|
|
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{overview.nextStep.reason}</Typography>
|
|
<Button variant="contained" endIcon={<ArrowForwardIcon />}
|
|
onClick={() => overview.nextStep?.section && onGo(overview.nextStep.section as WorkspaceSectionKey)}>
|
|
{overview.nextStep.label}
|
|
</Button>
|
|
</Paper>
|
|
) : (
|
|
<Alert severity="success" sx={{ borderRadius: 3 }}>
|
|
Nothing outstanding — this application is fully prepared.
|
|
</Alert>
|
|
)}
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.5 }}>
|
|
{stats.map((s) => (
|
|
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
|
|
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" }}>
|
|
{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>
|
|
))}
|
|
</Box>
|
|
|
|
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Recent activity</Typography>
|
|
<Button size="small" onClick={onReload}>Refresh</Button>
|
|
</Stack>
|
|
<Divider sx={{ my: 1 }} />
|
|
{overview.recentActivity.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">No activity recorded yet.</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()}</Typography>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Paper>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|