feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
CI and Deploy / test (push) Failing after 1m13s
CI and Deploy / deploy (push) Has been skipped

Every JobApplication gets a dedicated workspace at /applications/{id} — a
surface, not a new data store. It owns no data and duplicates none: CV comes
from the Phase 4 CvVariant lens, analysis/match/interview from the existing
AiWorkspacePanel, documents from Attachments, communication from
Correspondence, activity from JobEvent, stage semantics from JobPipeline. No
career data is copied and nothing here writes.

- GET /api/jobapplications/{id}/workspace: one aggregate read (role, company,
  stage, dates, attached CV variant, cover letter, documents, AI history,
  recent activity) replacing the page fanning out across endpoints
- Next recommended action: ordered rules answering "what do I do next?", the
  core product principle for this phase
- ApplicationWorkspacePage: left nav + linkable ?section=, reusing the existing
  component for each domain; later-milestone sections say so rather than faking
- Entry point from the job dialog via an optional onOpenWorkspace callback —
  the dialog must not depend on router context (it is mounted without a
  <Router> in several suites), so the caller owns navigation
- 8 backend tests (aggregate, CV variant surfacing, counts, activity ordering,
  next-step rules, tenant scoping)

Local: 314 backend, 88/88 frontend (31 suites), tsc clean, production build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 23:57:24 +02:00
parent 3b59152782
commit e55a6e86b7
10 changed files with 797 additions and 2 deletions
+2
View File
@@ -50,6 +50,7 @@ const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"))
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
const CvBuilderPage = lazy(() => import("./views/CvBuilderPage"));
const ApplicationWorkspacePage = lazy(() => import("./views/ApplicationWorkspacePage"));
const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor"));
const PublicCvPage = lazy(() => import("./views/PublicCvPage"));
const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage"));
@@ -326,6 +327,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/correspondence/review" element={<GmailReviewPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/applications/:id" element={<ApplicationWorkspacePage />} />
<Route path="/career" element={<CareerWorkspacePage />} />
<Route path="/career/builder" element={<CvBuilderPage />} />
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
@@ -0,0 +1,65 @@
import { api } from "./api";
// Mirrors WorkspaceOverviewDto. Read-only aggregate — the workspace owns no data.
export type WorkspaceCv = {
variantId: number | null;
variantName: string | null;
themeId: string | null;
hasTailoredCvText: boolean;
updatedAtUtc: string | null;
};
export type WorkspaceActivity = { type: string; detail: string | null; at: string };
export type WorkspaceNextStep = { key: string; label: string; reason: string; section: string | null };
export type WorkspaceOverview = {
id: number;
jobTitle: string;
company: string | null;
location: string | null;
salary: string | null;
status: string;
stageGroup: string;
stageOrder: number;
dateApplied: string | null;
deadline: string | null;
followUpAt: string | null;
nextAction: string | null;
jobUrl: string | null;
hasJobDescription: boolean;
cv: WorkspaceCv;
hasCoverLetter: boolean;
documentCount: number;
hasPortfolio: boolean;
aiInteractionCount: number;
lastAiAtUtc: string | null;
recentActivity: WorkspaceActivity[];
nextStep: WorkspaceNextStep | null;
};
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
// so the workspace is always usable rather than a shell of placeholders.
export type WorkspaceSectionKey =
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
| "portfolio" | "documents" | "interview" | "timeline" | "notes" | "communication";
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; milestone?: number }[] = [
{ key: "overview", label: "Overview" },
{ key: "job-details", label: "Job Details" },
{ key: "analysis", label: "Analysis" },
{ key: "match", label: "Match" },
{ key: "checklist", label: "Checklist", milestone: 2 },
{ key: "cv", label: "CV", milestone: 6 },
{ key: "cover-letter", label: "Cover Letter", milestone: 7 },
{ key: "portfolio", label: "Portfolio", milestone: 8 },
{ key: "documents", label: "Documents" },
{ key: "interview", label: "Interview Prep" },
{ key: "timeline", label: "Timeline", milestone: 3 },
{ key: "notes", label: "Notes", milestone: 3 },
{ key: "communication", label: "Communication" },
];
export const applicationWorkspaceApi = {
overview: (jobId: number) =>
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
};
@@ -50,6 +50,9 @@ interface Props {
onClose: () => void;
initialTab?: number;
initialFollowUpMode?: string;
// Supplied by callers that live inside the router. Optional on purpose: the dialog must not depend
// on router context, so it stays renderable standalone (and in tests) without a <Router>.
onOpenWorkspace?: (jobId: number) => void;
}
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
@@ -128,7 +131,7 @@ function serializeTailoredDraft(draft: TailoredCvDraft) {
});
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode, onOpenWorkspace }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
@@ -680,6 +683,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
{/* Phase 5: the full-page Application Workspace. The dialog stays as the quick view. */}
{jobId && onOpenWorkspace ? (
<Button size="small" variant="outlined" sx={{ mt: 1.5 }} onClick={() => onOpenWorkspace(jobId)}>
Open application workspace
</Button>
) : null}
</Box>
<Tabs
value={tab}
+1 -1
View File
@@ -776,7 +776,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} />
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); navigate(`/applications/${id}`); }} />
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })}</MenuItem>)}
@@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { 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 { getApiErrorMessage } from "../api";
import Attachments from "../components/Attachments";
import Correspondence from "../components/Correspondence";
import AiWorkspacePanel from "../components/AiWorkspacePanel";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
} 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).
// Sections belonging to later milestones say so rather than pretending to work.
// docs/architecture/application-workspace.md.
export default function ApplicationWorkspacePage() {
const { id } = useParams();
const jobId = Number(id);
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const section = (params.get("section") as WorkspaceSectionKey) || "overview";
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setOverview(await applicationWorkspaceApi.overview(jobId));
} catch (err) {
setError(getApiErrorMessage(err, "Could not open this application."));
}
}, [jobId]);
useEffect(() => {
load();
}, [load]);
const go = (next: WorkspaceSectionKey) => setParams({ section: next }, { replace: true });
if (error) {
return (
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/jobs")}>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={() => navigate("/jobs")}>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
Workspace
</Typography>
</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}
secondary={s.milestone ? `Milestone ${s.milestone}` : undefined}
slotProps={{ primary: { fontSize: 14, fontWeight: section === s.key ? 700 : 500 }, secondary: { fontSize: 11 } }}
/>
</ListItemButton>
))}
</List>
</Paper>
<Box sx={{ display: "grid", gap: 2 }}>
<WorkspaceHeader overview={overview} />
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />}
{section === "job-details" && <JobDetailsSection overview={overview} />}
{(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} job={null as any} /></Paper>
)}
{["checklist", "cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && (
<ComingInMilestone section={section} />
)}
</Box>
</Box>
);
}
function WorkspaceHeader({ overview }: { overview: WorkspaceOverview | null }) {
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">
<Chip size="small" label={overview.status} color="primary" variant="outlined" />
<Chip size="small" label={overview.stageGroup} />
{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 },
] : [], [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(4, 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 }: { overview: WorkspaceOverview | null }) {
if (!overview) return <Skeleton variant="rounded" height={200} />;
const rows: [string, string][] = [
["Company", overview.company ?? "—"],
["Location", overview.location ?? "—"],
["Salary", overview.salary ?? "—"],
["Status", overview.status],
["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>
);
}
function ComingInMilestone({ section }: { section: string }) {
const m = WORKSPACE_SECTIONS.find((s) => s.key === section);
return (
<Paper sx={{ p: 3, borderRadius: 3, textAlign: "center" }}>
<Typography sx={{ fontWeight: 700 }}>{m?.label}</Typography>
<Typography color="text.secondary">
Arriving in Phase 5 milestone {m?.milestone}. Existing tools for this remain available in the application dialog.
</Typography>
</Paper>
);
}