refactor(workspace): unify application workflow
This commit is contained in:
@@ -89,6 +89,24 @@ public sealed class ApplicationIntelligenceTests
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static async Task<CvVariant> 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]
|
||||
|
||||
@@ -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<MatchEvidenceDto> RelevantExperience,
|
||||
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
|
||||
IReadOnlyList<string> 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<string>(), Array.Empty<string>(),
|
||||
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
|
||||
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<string>(), Array.Empty<string>(),
|
||||
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
|
||||
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<CareerExperience>();
|
||||
var projects = projectsVisible
|
||||
? profile.Projects.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
|
||||
: new List<CareerProject>();
|
||||
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<string, string>(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)
|
||||
{
|
||||
|
||||
@@ -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(<ApplicationMatch jobId={7} />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ jest.mock("./components/ApplicationAssets", () => ({
|
||||
ApplicationPackageDraftsSection: () => <div>Application drafts section</div>,
|
||||
}));
|
||||
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
|
||||
jest.mock("./components/ApplicationWorkflowAssist", () => ({
|
||||
ApplicationStatusSuggestion: () => <div>Status suggestion</div>,
|
||||
ApplicationStrategySnapshot: () => <div>Strategy section</div>,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<string, WorkspaceSectionKey> = {
|
||||
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<string, string> = {
|
||||
|
||||
@@ -262,16 +262,21 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Match"
|
||||
subtitle="Your master career profile against this advert. Reads your profile; never changes it."
|
||||
title="CV Match"
|
||||
subtitle={data?.selectedCvName
|
||||
? `${data.selectedCvName} compared with this job advert.`
|
||||
: "Choose the CV intended for this application before comparing it with the advert."}
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{data && !data.hasCareerProfile ? (
|
||||
{data && !data.hasSelectedCv ? (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
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.
|
||||
</Alert>
|
||||
) : data && !data.hasCareerProfile ? (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
The linked CV cannot be matched until its career profile has content.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<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 }}>
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
||||
<Tooltip title="Back to applications">
|
||||
<IconButton size="small" aria-label="Back to applications" onClick={close}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
@@ -176,45 +178,30 @@ export function ApplicationWorkspace({
|
||||
</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>
|
||||
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
||||
<Tabs
|
||||
value={section}
|
||||
onChange={(_, value: WorkspaceSectionKey) => 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) => <Tab key={s.key} value={s.key} label={s.label} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
||||
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
|
||||
{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 === "analysis" && jobId > 0 && <ApplicationStrategySnapshot jobId={jobId} />}
|
||||
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
|
||||
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
||||
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
|
||||
{section === "analysis" && jobId > 0 && (
|
||||
<Stack spacing={2}>
|
||||
<ApplicationAnalysis jobId={jobId} />
|
||||
<ApplicationMatch jobId={jobId} />
|
||||
<ApplicationStrategySnapshot jobId={jobId} />
|
||||
</Stack>
|
||||
)}
|
||||
{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 && (
|
||||
<>
|
||||
@@ -226,8 +213,6 @@ export function ApplicationWorkspace({
|
||||
onSaved={load}
|
||||
onDirtyChange={setPackageDraftsDirty}
|
||||
/>
|
||||
{/* Generation stays an explicit user action, below the editor the user owns. */}
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -241,9 +226,10 @@ export function ApplicationWorkspace({
|
||||
);
|
||||
}
|
||||
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||
const { t } = useI18n();
|
||||
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 }}>
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2.5 }}>
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
|
||||
@@ -257,8 +243,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Chip size="small" label={overview.status} color="primary" variant="outlined" />
|
||||
<Chip size="small" label={overview.stageGroup} />
|
||||
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
|
||||
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
||||
{overview.jobUrl && (
|
||||
<Tooltip title="Open original advert">
|
||||
@@ -269,21 +254,59 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<ApplicationProgress status={overview.status} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ mt: 2.5 }} aria-label={`Application progress: ${statusLabel(t, status)}`}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
|
||||
Application progress
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
|
||||
{stages.map((stage, index) => {
|
||||
const order = PIPELINE_STATUSES.indexOf(stage) + 1;
|
||||
const current = stage === normalized;
|
||||
const complete = !current && order < currentOrder;
|
||||
return (
|
||||
<Box key={stage} sx={{ display: "grid", gridTemplateColumns: index ? "minmax(30px, 1fr) auto" : "auto", alignItems: "center", minWidth: index ? 110 : 72, flex: 1 }}>
|
||||
{index > 0 && <Box aria-hidden sx={{ height: 2, bgcolor: complete || current ? "primary.main" : "divider" }} />}
|
||||
<Stack alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
|
||||
<Box aria-hidden sx={{ width: 12, height: 12, borderRadius: "50%", border: 2, borderColor: current || complete ? "primary.main" : "divider", bgcolor: complete ? "primary.main" : "background.paper", boxShadow: current ? (theme) => `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} />
|
||||
<Typography variant="caption" sx={{ whiteSpace: "nowrap", fontWeight: current ? 800 : 600, color: current ? "text.primary" : "text.secondary" }}>
|
||||
{statusLabel(t, stage)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
||||
overview: WorkspaceOverview | null;
|
||||
onGo: (s: WorkspaceSectionKey) => void;
|
||||
onReload: () => void;
|
||||
onEdit: () => 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: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "overview" 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 },
|
||||
{ icon: <ChecklistIcon fontSize="small" />, 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 }: {
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
|
||||
const panels = [
|
||||
{ id: "details", title: "Job details", content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||
{ id: "tasks", title: "Next actions and checklist", content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
||||
{ id: "timeline", title: "Activity history", content: <ApplicationTimeline jobId={jobId} /> },
|
||||
{ id: "documents", title: "Documents", content: <Attachments jobId={jobId} /> },
|
||||
{ id: "communication", title: "Communication", content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
||||
];
|
||||
return (
|
||||
<Box>
|
||||
{panels.map((panel) => (
|
||||
<Accordion key={panel.id} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${panel.id}-content`} id={`${panel.id}-header`}>
|
||||
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails id={`${panel.id}-content`} sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||
if (!overview) return <Skeleton variant="rounded" height={200} />;
|
||||
const rows: [string, string][] = [
|
||||
|
||||
Reference in New Issue
Block a user