From e3f938cb42f65b516be0b8ff861a6e7e17505038 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 28 Aug 2026 12:28:12 +0200 Subject: [PATCH] refactor(workspace): unify application workflow --- .../ApplicationIntelligenceTests.cs | 49 +++++- .../ApplicationIntelligenceService.cs | 95 ++++++++--- .../src/application-intelligence.test.tsx | 4 +- .../application-workspace-overlay.test.tsx | 18 ++- .../src/applicationWorkspace.test.ts | 10 +- job-tracker-ui/src/applicationWorkspace.ts | 28 ++-- .../components/ApplicationIntelligence.tsx | 15 +- .../src/views/ApplicationWorkspacePage.tsx | 147 ++++++++++++------ 8 files changed, 264 insertions(+), 102 deletions(-) diff --git a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs index c43caf0..f08c208 100644 --- a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs +++ b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs @@ -89,6 +89,24 @@ public sealed class ApplicationIntelligenceTests return profile; } + private static async Task AttachCvAsync(JobTrackerContext db, string owner, int jobId, CvVariantSettings? settings = null) + { + var variant = new CvVariant + { + OwnerUserId = owner, + JobApplicationId = jobId, + Name = "Backend CV", + PublicSlug = Guid.NewGuid().ToString("N"), + SettingsJson = CvVariantSettingsJson.Serialize(settings), + Version = 1, + CreatedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + }; + db.CvVariants.Add(variant); + await db.SaveChangesAsync(); + return variant; + } + // ---------- Milestone 1: timeline ---------- [Fact] @@ -262,11 +280,14 @@ public sealed class ApplicationIntelligenceTests await using var _d = db; var job = await SeedJobAsync(db, "user-1"); await SeedProfileAsync(db, "user-1"); + await AttachCvAsync(db, "user-1", job.Id); var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.NotNull(m); - Assert.True(m!.HasCareerProfile); + Assert.True(m!.HasSelectedCv); + Assert.Equal("Backend CV", m.SelectedCvName); + Assert.True(m.HasCareerProfile); Assert.True(m.Score > 0); Assert.Contains("C#", m.MatchedSkills); // The relevant-experience list is evidence drawn FROM the profile, not a copy of it. @@ -282,6 +303,7 @@ public sealed class ApplicationIntelligenceTests await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var profile = await SeedProfileAsync(db, "user-1"); + await AttachCvAsync(db, "user-1", job.Id); var bulletsBefore = profile.Experiences[0].BulletsJson; var experienceCountBefore = profile.Experiences.Count; var versionBefore = profile.Version; @@ -295,7 +317,7 @@ public sealed class ApplicationIntelligenceTests } [Fact] - public async Task Match_asks_for_a_profile_before_scoring_anything() + public async Task Match_requires_an_explicitly_linked_cv_before_scoring_anything() { var (db, intelligence, _) = New("user-1"); await using var _d = db; @@ -304,8 +326,9 @@ public sealed class ApplicationIntelligenceTests var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.False(m!.HasCareerProfile); + Assert.False(m.HasSelectedCv); Assert.Equal(0, m.Score); - Assert.Contains(m.Suggestions, s => s.Contains("career profile", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(m.Suggestions, s => s.Contains("Select the CV", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -315,10 +338,30 @@ public sealed class ApplicationIntelligenceTests await using var _d = db; var job = await SeedJobAsync(db, "user-1"); await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored + await AttachCvAsync(db, "user-1", job.Id); var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.False(m!.HasCareerProfile); + Assert.True(m.HasSelectedCv); + } + + [Fact] + public async Task Match_respects_sections_hidden_in_the_linked_cv() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings + { + Sections = { new CvSectionSetting { Key = "experience", Hidden = true } }, + }); + + var match = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.True(match!.HasSelectedCv); + Assert.Empty(match.RelevantExperience); } [Fact] diff --git a/JobTrackerApi/Services/ApplicationIntelligenceService.cs b/JobTrackerApi/Services/ApplicationIntelligenceService.cs index bcf4620..deb711c 100644 --- a/JobTrackerApi/Services/ApplicationIntelligenceService.cs +++ b/JobTrackerApi/Services/ApplicationIntelligenceService.cs @@ -11,7 +11,7 @@ namespace JobTrackerApi.Services; // Phase 5.3 Milestones 2 and 3 — job analysis and career matching. // // DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns -// (the advert on the JobApplication, the master CareerProfile) using the existing SkillTagger and +// (the advert on the JobApplication, the explicitly linked CV lens over CareerProfile) using the existing SkillTagger and // JobCvMatchService. The same input always gives the same number, so the score is something a user // can trust and re-check. // @@ -49,7 +49,9 @@ public sealed record CareerMatchDto( IReadOnlyList RelevantExperience, IReadOnlyList RelevantProjects, IReadOnlyList Suggestions, - int AiSuggestionCount); + int AiSuggestionCount, + bool HasSelectedCv = false, + string? SelectedCvName = null); public interface IApplicationIntelligenceService { @@ -195,55 +197,91 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); if (job is null) return null; - // READ ONLY. The master profile is the single source of truth and nothing here writes to it. + // A job-specific match must never silently pick an arbitrary CV. The attached variant is a + // lens over the master profile, so matching it remains read-only while respecting what the + // user actually plans to send (hidden sections/items and CV-specific wording). + var attachedQuery = _db.CvVariants.AsNoTracking() + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId); + var attached = _db.Database.IsSqlite() + ? (await attachedQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc) + : await attachedQuery.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct); + + var aiCount = await _db.AiInteractions.AsNoTracking() + .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct); + + if (attached is null) + { + return new CareerMatchDto(0, "Select a CV", false, false, + Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), + new[] { "Select the CV you plan to send before running a job-specific match." }, + aiCount, HasSelectedCv: false); + } + var profile = await _db.CareerProfiles.AsNoTracking() .Include(p => p.Experiences) .Include(p => p.Projects) .Include(p => p.Skills) .FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct); - var aiCount = await _db.AiInteractions.AsNoTracking() - .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct); - if (profile is null) { return new CareerMatchDto(0, "No profile", false, false, Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { "Build your career profile first — matching compares the advert against it." }, - aiCount); + aiCount, HasSelectedCv: true, SelectedCvName: attached.Name); } + var settings = CvVariantSettingsJson.Deserialize(attached.SettingsJson); + var experienceVisible = SectionVisible(settings, "experience"); + var projectsVisible = SectionVisible(settings, "projects"); + var skillsVisible = SectionVisible(settings, "skills"); + var experiences = experienceVisible + ? profile.Experiences.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList() + : new List(); + var projects = projectsVisible + ? profile.Projects.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList() + : new List(); + var skillItems = settings.Sections.FirstOrDefault(section => string.Equals(section.Key, "skills", StringComparison.OrdinalIgnoreCase))?.Items; + var skillsText = !skillsVisible + ? string.Empty + : skillItems is not null + ? string.Join("\n", skillItems) + : string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")); + // Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores // identically whichever surface asks. var sections = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["Experience"] = string.Join("\n", profile.Experiences.Select(ExperienceText)), - ["Projects"] = string.Join("\n", profile.Projects.Select(ProjectText)), - ["Skills"] = string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")), + ["Experience"] = string.Join("\n", experiences.Select(entry => ExperienceText(entry, Override(settings, entry.ItemKey)))), + ["Projects"] = string.Join("\n", projects.Select(entry => ProjectText(entry, Override(settings, entry.ItemKey)))), + ["Skills"] = skillsText, }; var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections); - var relevantExperience = profile.Experiences - .Select(e => new { Entry = e, Hits = HitsFor(ExperienceText(e), result.MatchedKeywords) }) + var relevantExperience = experiences + .Select(e => new { Entry = e, Override = Override(settings, e.ItemKey) }) + .Select(x => new { x.Entry, x.Override, Hits = HitsFor(ExperienceText(x.Entry, x.Override), result.MatchedKeywords) }) .Where(x => x.Hits.Count > 0) .OrderByDescending(x => x.Hits.Count) .Take(MaxEvidence) .Select(x => new MatchEvidenceDto( - x.Entry.Title ?? "Untitled role", - string.Join(" · ", new[] { x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))), + x.Override.Title ?? x.Entry.Title ?? "Untitled role", + string.Join(" · ", new[] { x.Override.Subtitle ?? x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))), x.Hits)) .ToList(); - var relevantProjects = profile.Projects - .Select(p => new { Entry = p, Hits = HitsFor(ProjectText(p), result.MatchedKeywords) }) + var relevantProjects = projects + .Select(p => new { Entry = p, Override = Override(settings, p.ItemKey) }) + .Select(x => new { x.Entry, x.Override, Hits = HitsFor(ProjectText(x.Entry, x.Override), result.MatchedKeywords) }) .Where(x => x.Hits.Count > 0) .OrderByDescending(x => x.Hits.Count) .Take(MaxEvidence) .Select(x => new MatchEvidenceDto( - x.Entry.Name ?? "Untitled project", - Blank(x.Entry.Role), + x.Override.Title ?? x.Entry.Name ?? "Untitled project", + Blank(x.Override.Subtitle ?? x.Entry.Role), x.Hits)) .ToList(); @@ -257,7 +295,9 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer RelevantExperience: relevantExperience, RelevantProjects: relevantProjects, Suggestions: Suggestions(result, relevantExperience.Count), - AiSuggestionCount: aiCount); + AiSuggestionCount: aiCount, + HasSelectedCv: true, + SelectedCvName: attached.Name); } // Suggestions describe what the USER could change. They never edit anything themselves. @@ -299,11 +339,20 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer // ---------- shared ---------- - private static string ExperienceText(CareerExperience e) => - $"{e.Title} {e.Company} {e.Location} {ReadJsonArray(e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}"; + private static string ExperienceText(CareerExperience e, CvItemOverride? itemOverride = null) => + $"{itemOverride?.Title ?? e.Title} {itemOverride?.Subtitle ?? e.Company} {e.Location} {OverrideBullets(itemOverride, e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}"; - private static string ProjectText(CareerProject p) => - $"{p.Name} {p.Role} {ReadJsonArray(p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}"; + private static string ProjectText(CareerProject p, CvItemOverride? itemOverride = null) => + $"{itemOverride?.Title ?? p.Name} {itemOverride?.Subtitle ?? p.Role} {OverrideBullets(itemOverride, p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}"; + + private static string OverrideBullets(CvItemOverride? itemOverride, string fallbackJson) => + itemOverride?.Bullets is null ? ReadJsonArray(fallbackJson) : string.Join(" ", itemOverride.Bullets); + + private static CvItemOverride Override(CvVariantSettings settings, string itemKey) => + settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride(); + + private static bool SectionVisible(CvVariantSettings settings, string key) => + settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true; private static string ReadJsonArray(string? json) { diff --git a/job-tracker-ui/src/application-intelligence.test.tsx b/job-tracker-ui/src/application-intelligence.test.tsx index eda9deb..efdef05 100644 --- a/job-tracker-ui/src/application-intelligence.test.tsx +++ b/job-tracker-ui/src/application-intelligence.test.tsx @@ -59,6 +59,8 @@ const match = { band: "Good", hasEnoughSignal: true, hasCareerProfile: true, + hasSelectedCv: true, + selectedCvName: "Backend CV", matchedSkills: ["C#", "SQL"], missingSkills: ["Kubernetes"], relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }], @@ -167,7 +169,7 @@ test("match asks for a career profile before showing a score", async () => { render(); - expect(await screen.findByText(/No career profile yet/i)).toBeInTheDocument(); + expect(await screen.findByText(/linked CV cannot be matched/i)).toBeInTheDocument(); expect(screen.queryByText("0%")).not.toBeInTheDocument(); }); diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx index ee784b9..e074e6e 100644 --- a/job-tracker-ui/src/application-workspace-overlay.test.tsx +++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx @@ -31,6 +31,10 @@ jest.mock("./components/ApplicationAssets", () => ({ ApplicationPackageDraftsSection: () =>
Application drafts section
, })); jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () =>
Interview section
})); +jest.mock("./components/ApplicationWorkflowAssist", () => ({ + ApplicationStatusSuggestion: () =>
Status suggestion
, + ApplicationStrategySnapshot: () =>
Strategy section
, +})); const mockedApi = api as jest.Mocked; @@ -167,9 +171,10 @@ test("opens the dedicated workspace from the whole row and preserves list state expect(await screen.findByText("Backend Developer")).toBeInTheDocument(); expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42"); - fireEvent.click(screen.getByRole("button", { name: "Match" })); + fireEvent.click(screen.getByRole("tab", { name: "Analysis" })); + expect(await screen.findByText("Analysis section")).toBeInTheDocument(); expect(await screen.findByText("Match section")).toBeInTheDocument(); - expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match"); + expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis"); fireEvent.click(screen.getByRole("button", { name: /back to applications/i })); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend")); @@ -180,7 +185,10 @@ test("opens the dedicated workspace from the whole row and preserves list state test("opens a direct workspace URL and returns to applications", async () => { renderTable("/jobs/42?section=match"); + expect(await screen.findByText("Analysis section")).toBeInTheDocument(); expect(await screen.findByText("Match section")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByLabelText(/application progress: waiting/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /back to applications/i })); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs")); @@ -208,16 +216,16 @@ test("warns before section navigation would discard application edits", async () renderTable("/jobs/42?section=cover-letter"); fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" })); - fireEvent.click(screen.getByRole("button", { name: "Match" })); + fireEvent.click(screen.getByRole("tab", { name: "Analysis" })); expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Keep editing" })); expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter"); await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument()); - fireEvent.click(screen.getByRole("button", { name: "Match" })); + fireEvent.click(screen.getByRole("tab", { name: "Analysis" })); fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" })); - await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match")); + await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis")); }); test("hydrates list filters, sort and page from a shareable URL", async () => { diff --git a/job-tracker-ui/src/applicationWorkspace.test.ts b/job-tracker-ui/src/applicationWorkspace.test.ts index 6342d27..6016509 100644 --- a/job-tracker-ui/src/applicationWorkspace.test.ts +++ b/job-tracker-ui/src/applicationWorkspace.test.ts @@ -1,8 +1,10 @@ import { WORKSPACE_SECTIONS, workspaceSection } from "./applicationWorkspace"; -test("workspace navigation rejects removed and unknown sections", () => { - expect(WORKSPACE_SECTIONS.map((section) => section.key)).not.toContain("portfolio"); - expect(WORKSPACE_SECTIONS.map((section) => section.key)).not.toContain("notes"); - expect(workspaceSection("match")).toBe("match"); +test("workspace navigation exposes five purposes and preserves legacy links", () => { + expect(WORKSPACE_SECTIONS.map((section) => section.key)).toEqual([ + "overview", "analysis", "cv", "cover-letter", "interview", + ]); + expect(workspaceSection("match")).toBe("analysis"); + expect(workspaceSection("timeline")).toBe("overview"); expect(workspaceSection("portfolio")).toBe("overview"); }); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index e796efe..958daa3 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -79,26 +79,30 @@ export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [ // 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" - | "documents" | "interview" | "timeline" | "communication"; +export type WorkspaceSectionKey = "overview" | "analysis" | "cv" | "cover-letter" | "interview"; export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [ { key: "overview", label: "Overview" }, - { key: "job-details", label: "Job Details" }, { key: "analysis", label: "Analysis" }, - { key: "match", label: "Match" }, - { key: "checklist", label: "Checklist" }, { key: "cv", label: "CV" }, { key: "cover-letter", label: "Cover Letter" }, - { key: "documents", label: "Documents" }, { key: "interview", label: "Interview Prep" }, - { key: "timeline", label: "Timeline" }, - { key: "communication", label: "Communication" }, ]; -export const workspaceSection = (value: string | null): WorkspaceSectionKey => - WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview"; +const LEGACY_WORKSPACE_SECTIONS: Record = { + match: "analysis", + "job-details": "overview", + checklist: "overview", + documents: "overview", + timeline: "overview", + communication: "overview", +}; + +/** Preserve old shared/bookmarked workspace links while keeping one clear primary navigation. */ +export const workspaceSection = (value: string | null): WorkspaceSectionKey => { + if (value && value in LEGACY_WORKSPACE_SECTIONS) return LEGACY_WORKSPACE_SECTIONS[value]; + return WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview"; +}; export const applicationWorkspaceApi = { overview: (jobId: number) => @@ -158,6 +162,8 @@ export type CareerMatch = { relevantProjects: MatchEvidence[]; suggestions: string[]; aiSuggestionCount: number; + hasSelectedCv: boolean; + selectedCvName: string | null; }; export const TIMELINE_CATEGORY_LABELS: Record = { diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx index 5d4cd84..8bcdb55 100644 --- a/job-tracker-ui/src/components/ApplicationIntelligence.tsx +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -262,16 +262,21 @@ export function ApplicationMatch({ jobId }: { jobId: number }) { return ( - {data && !data.hasCareerProfile ? ( + {data && !data.hasSelectedCv ? ( - No career profile yet. Matching compares the advert against your master profile — build it - once and every application scores against it. + Select a CV on the CV tab first. The application will only analyse the document you explicitly link. + + ) : data && !data.hasCareerProfile ? ( + + The linked CV cannot be matched until its career profile has content. ) : ( <> diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 485a423..ad94150 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -4,8 +4,8 @@ import { } from "react-router-dom"; import { - Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper, - Skeleton, Stack, Tooltip, Typography, + Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, Divider, IconButton, Paper, + Skeleton, Stack, Tab, Tabs, Tooltip, Typography, } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; @@ -16,11 +16,11 @@ 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 ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 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, @@ -31,6 +31,8 @@ import { import { ApplicationInterviewPrep } from "../components/InterviewPrep"; import EditJobDialog from "../components/EditJobDialog"; import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist"; +import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../pipeline"; +import { useI18n } from "../i18n/I18nProvider"; import { useConfirm } from "../confirm"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection, @@ -149,9 +151,9 @@ export function ApplicationWorkspace({ } return ( - - - + + + @@ -176,45 +178,30 @@ export function ApplicationWorkspace({ ) : null} - - {WORKSPACE_SECTIONS.map((s) => ( - go(s.key)} sx={{ borderRadius: 2 }}> - - - ))} - + setEditOpen(true)} /> + go(value)} + variant="scrollable" + scrollButtons="auto" + aria-label="Workspace sections" + sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }} + > + {WORKSPACE_SECTIONS.map((s) => )} + - - setEditOpen(true)} /> + {section === "overview" && jobId > 0 && } - {section === "overview" && } - {section === "job-details" && setEditOpen(true)} />} - {/* Deterministic answer first, then the AI panel below it — the page never generates on load. */} - {section === "analysis" && jobId > 0 && } - {section === "analysis" && jobId > 0 && } - {section === "match" && jobId > 0 && } - {section === "timeline" && jobId > 0 && } + {section === "overview" && setEditOpen(true)} />} + {section === "analysis" && jobId > 0 && ( + + + + + + )} {section === "interview" && jobId > 0 && } - {(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && ( - - - - )} - {section === "documents" && jobId > 0 && ( - - )} - {section === "communication" && jobId > 0 && ( - - - - )} - {section === "checklist" && jobId > 0 && ( - - )} {section === "cv" && jobId > 0 && } {section === "cover-letter" && jobId > 0 && ( <> @@ -226,8 +213,6 @@ export function ApplicationWorkspace({ onSaved={load} onDirtyChange={setPackageDraftsDirty} /> - {/* Generation stays an explicit user action, below the editor the user owns. */} - )} @@ -241,9 +226,10 @@ export function ApplicationWorkspace({ ); } function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { + const { t } = useI18n(); if (!overview) return ; return ( - + {overview.jobTitle} @@ -257,8 +243,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n - - + {overview.source ? : null} {overview.jobUrl && ( @@ -269,21 +254,59 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n )} - + + ); } -function OverviewSection({ overview, onGo, onReload }: { +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 isTerminal = normalized === "Rejected" || normalized === "Ghosted" || normalized === "Withdrawn"; + const stages = isTerminal + ? [...PIPELINE_STATUSES.filter((stage) => !["Offer", "Rejected", "Ghosted", "Withdrawn"].includes(stage)), normalized] + : PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage)); + + return ( + + + Application progress + + + {stages.map((stage, index) => { + const order = PIPELINE_STATUSES.indexOf(stage) + 1; + const current = stage === normalized; + const complete = !current && order < currentOrder; + return ( + + {index > 0 && } + + `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} /> + + {statusLabel(t, stage)} + + + + ); + })} + + + ); +} + +function OverviewSection({ overview, onGo, onReload, onEdit }: { overview: WorkspaceOverview | null; onGo: (s: WorkspaceSectionKey) => void; onReload: () => void; + onEdit: () => void; }) { const stats = useMemo(() => overview ? [ { icon: , 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: , label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const }, - { icon: , label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const }, + { icon: , label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "overview" as const }, { icon: , label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const }, - { icon: , label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const }, + { icon: , label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const }, ] : [], [overview]); if (!overview) { @@ -342,10 +365,34 @@ function OverviewSection({ overview, onGo, onReload }: { )} + + ); } +function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) { + const panels = [ + { id: "details", title: "Job details", content: }, + { id: "tasks", title: "Next actions and checklist", content: }, + { id: "timeline", title: "Activity history", content: }, + { id: "documents", title: "Documents", content: }, + { id: "communication", title: "Communication", content: }, + ]; + return ( + + {panels.map((panel) => ( + + } aria-controls={`${panel.id}-content`} id={`${panel.id}-header`}> + {panel.title} + + {panel.content} + + ))} + + ); +} + function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) { if (!overview) return ; const rows: [string, string][] = [