refactor(workspace): unify application workflow
This commit is contained in:
@@ -89,6 +89,24 @@ public sealed class ApplicationIntelligenceTests
|
|||||||
return profile;
|
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 ----------
|
// ---------- Milestone 1: timeline ----------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -262,11 +280,14 @@ public sealed class ApplicationIntelligenceTests
|
|||||||
await using var _d = db;
|
await using var _d = db;
|
||||||
var job = await SeedJobAsync(db, "user-1");
|
var job = await SeedJobAsync(db, "user-1");
|
||||||
await SeedProfileAsync(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);
|
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||||
|
|
||||||
Assert.NotNull(m);
|
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.True(m.Score > 0);
|
||||||
Assert.Contains("C#", m.MatchedSkills);
|
Assert.Contains("C#", m.MatchedSkills);
|
||||||
// The relevant-experience list is evidence drawn FROM the profile, not a copy of it.
|
// 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;
|
await using var _d = db;
|
||||||
var job = await SeedJobAsync(db, "user-1");
|
var job = await SeedJobAsync(db, "user-1");
|
||||||
var profile = await SeedProfileAsync(db, "user-1");
|
var profile = await SeedProfileAsync(db, "user-1");
|
||||||
|
await AttachCvAsync(db, "user-1", job.Id);
|
||||||
var bulletsBefore = profile.Experiences[0].BulletsJson;
|
var bulletsBefore = profile.Experiences[0].BulletsJson;
|
||||||
var experienceCountBefore = profile.Experiences.Count;
|
var experienceCountBefore = profile.Experiences.Count;
|
||||||
var versionBefore = profile.Version;
|
var versionBefore = profile.Version;
|
||||||
@@ -295,7 +317,7 @@ public sealed class ApplicationIntelligenceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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");
|
var (db, intelligence, _) = New("user-1");
|
||||||
await using var _d = db;
|
await using var _d = db;
|
||||||
@@ -304,8 +326,9 @@ public sealed class ApplicationIntelligenceTests
|
|||||||
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||||
|
|
||||||
Assert.False(m!.HasCareerProfile);
|
Assert.False(m!.HasCareerProfile);
|
||||||
|
Assert.False(m.HasSelectedCv);
|
||||||
Assert.Equal(0, m.Score);
|
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]
|
[Fact]
|
||||||
@@ -315,10 +338,30 @@ public sealed class ApplicationIntelligenceTests
|
|||||||
await using var _d = db;
|
await using var _d = db;
|
||||||
var job = await SeedJobAsync(db, "user-1");
|
var job = await SeedJobAsync(db, "user-1");
|
||||||
await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored
|
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);
|
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||||
|
|
||||||
Assert.False(m!.HasCareerProfile);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace JobTrackerApi.Services;
|
|||||||
// Phase 5.3 Milestones 2 and 3 — job analysis and career matching.
|
// 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
|
// 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
|
// JobCvMatchService. The same input always gives the same number, so the score is something a user
|
||||||
// can trust and re-check.
|
// can trust and re-check.
|
||||||
//
|
//
|
||||||
@@ -49,7 +49,9 @@ public sealed record CareerMatchDto(
|
|||||||
IReadOnlyList<MatchEvidenceDto> RelevantExperience,
|
IReadOnlyList<MatchEvidenceDto> RelevantExperience,
|
||||||
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
|
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
|
||||||
IReadOnlyList<string> Suggestions,
|
IReadOnlyList<string> Suggestions,
|
||||||
int AiSuggestionCount);
|
int AiSuggestionCount,
|
||||||
|
bool HasSelectedCv = false,
|
||||||
|
string? SelectedCvName = null);
|
||||||
|
|
||||||
public interface IApplicationIntelligenceService
|
public interface IApplicationIntelligenceService
|
||||||
{
|
{
|
||||||
@@ -195,55 +197,91 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
|||||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||||
if (job is null) return null;
|
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()
|
var profile = await _db.CareerProfiles.AsNoTracking()
|
||||||
.Include(p => p.Experiences)
|
.Include(p => p.Experiences)
|
||||||
.Include(p => p.Projects)
|
.Include(p => p.Projects)
|
||||||
.Include(p => p.Skills)
|
.Include(p => p.Skills)
|
||||||
.FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct);
|
.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)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
return new CareerMatchDto(0, "No profile", false, false,
|
return new CareerMatchDto(0, "No profile", false, false,
|
||||||
Array.Empty<string>(), Array.Empty<string>(),
|
Array.Empty<string>(), Array.Empty<string>(),
|
||||||
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
|
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
|
||||||
new[] { "Build your career profile first — matching compares the advert against it." },
|
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
|
// Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores
|
||||||
// identically whichever surface asks.
|
// identically whichever surface asks.
|
||||||
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Experience"] = string.Join("\n", profile.Experiences.Select(ExperienceText)),
|
["Experience"] = string.Join("\n", experiences.Select(entry => ExperienceText(entry, Override(settings, entry.ItemKey)))),
|
||||||
["Projects"] = string.Join("\n", profile.Projects.Select(ProjectText)),
|
["Projects"] = string.Join("\n", projects.Select(entry => ProjectText(entry, Override(settings, entry.ItemKey)))),
|
||||||
["Skills"] = string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")),
|
["Skills"] = skillsText,
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections);
|
var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections);
|
||||||
|
|
||||||
var relevantExperience = profile.Experiences
|
var relevantExperience = experiences
|
||||||
.Select(e => new { Entry = e, Hits = HitsFor(ExperienceText(e), result.MatchedKeywords) })
|
.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)
|
.Where(x => x.Hits.Count > 0)
|
||||||
.OrderByDescending(x => x.Hits.Count)
|
.OrderByDescending(x => x.Hits.Count)
|
||||||
.Take(MaxEvidence)
|
.Take(MaxEvidence)
|
||||||
.Select(x => new MatchEvidenceDto(
|
.Select(x => new MatchEvidenceDto(
|
||||||
x.Entry.Title ?? "Untitled role",
|
x.Override.Title ?? 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))),
|
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))
|
x.Hits))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var relevantProjects = profile.Projects
|
var relevantProjects = projects
|
||||||
.Select(p => new { Entry = p, Hits = HitsFor(ProjectText(p), result.MatchedKeywords) })
|
.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)
|
.Where(x => x.Hits.Count > 0)
|
||||||
.OrderByDescending(x => x.Hits.Count)
|
.OrderByDescending(x => x.Hits.Count)
|
||||||
.Take(MaxEvidence)
|
.Take(MaxEvidence)
|
||||||
.Select(x => new MatchEvidenceDto(
|
.Select(x => new MatchEvidenceDto(
|
||||||
x.Entry.Name ?? "Untitled project",
|
x.Override.Title ?? x.Entry.Name ?? "Untitled project",
|
||||||
Blank(x.Entry.Role),
|
Blank(x.Override.Subtitle ?? x.Entry.Role),
|
||||||
x.Hits))
|
x.Hits))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -257,7 +295,9 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
|||||||
RelevantExperience: relevantExperience,
|
RelevantExperience: relevantExperience,
|
||||||
RelevantProjects: relevantProjects,
|
RelevantProjects: relevantProjects,
|
||||||
Suggestions: Suggestions(result, relevantExperience.Count),
|
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.
|
// Suggestions describe what the USER could change. They never edit anything themselves.
|
||||||
@@ -299,11 +339,20 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
|||||||
|
|
||||||
// ---------- shared ----------
|
// ---------- shared ----------
|
||||||
|
|
||||||
private static string ExperienceText(CareerExperience e) =>
|
private static string ExperienceText(CareerExperience e, CvItemOverride? itemOverride = null) =>
|
||||||
$"{e.Title} {e.Company} {e.Location} {ReadJsonArray(e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";
|
$"{itemOverride?.Title ?? e.Title} {itemOverride?.Subtitle ?? e.Company} {e.Location} {OverrideBullets(itemOverride, e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";
|
||||||
|
|
||||||
private static string ProjectText(CareerProject p) =>
|
private static string ProjectText(CareerProject p, CvItemOverride? itemOverride = null) =>
|
||||||
$"{p.Name} {p.Role} {ReadJsonArray(p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}";
|
$"{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)
|
private static string ReadJsonArray(string? json)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const match = {
|
|||||||
band: "Good",
|
band: "Good",
|
||||||
hasEnoughSignal: true,
|
hasEnoughSignal: true,
|
||||||
hasCareerProfile: true,
|
hasCareerProfile: true,
|
||||||
|
hasSelectedCv: true,
|
||||||
|
selectedCvName: "Backend CV",
|
||||||
matchedSkills: ["C#", "SQL"],
|
matchedSkills: ["C#", "SQL"],
|
||||||
missingSkills: ["Kubernetes"],
|
missingSkills: ["Kubernetes"],
|
||||||
relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }],
|
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} />);
|
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();
|
expect(screen.queryByText("0%")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ jest.mock("./components/ApplicationAssets", () => ({
|
|||||||
ApplicationPackageDraftsSection: () => <div>Application drafts section</div>,
|
ApplicationPackageDraftsSection: () => <div>Application drafts section</div>,
|
||||||
}));
|
}));
|
||||||
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview 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>;
|
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(await screen.findByText("Backend Developer")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42");
|
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(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 }));
|
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
|
||||||
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend"));
|
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 () => {
|
test("opens a direct workspace URL and returns to applications", async () => {
|
||||||
renderTable("/jobs/42?section=match");
|
renderTable("/jobs/42?section=match");
|
||||||
|
|
||||||
|
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("Match 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 }));
|
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
|
||||||
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs"));
|
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");
|
renderTable("/jobs/42?section=cover-letter");
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" }));
|
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();
|
expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Keep editing" }));
|
fireEvent.click(screen.getByRole("button", { name: "Keep editing" }));
|
||||||
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter");
|
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter");
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument());
|
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" }));
|
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 () => {
|
test("hydrates list filters, sort and page from a shareable URL", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { WORKSPACE_SECTIONS, workspaceSection } from "./applicationWorkspace";
|
import { WORKSPACE_SECTIONS, workspaceSection } from "./applicationWorkspace";
|
||||||
|
|
||||||
test("workspace navigation rejects removed and unknown sections", () => {
|
test("workspace navigation exposes five purposes and preserves legacy links", () => {
|
||||||
expect(WORKSPACE_SECTIONS.map((section) => section.key)).not.toContain("portfolio");
|
expect(WORKSPACE_SECTIONS.map((section) => section.key)).toEqual([
|
||||||
expect(WORKSPACE_SECTIONS.map((section) => section.key)).not.toContain("notes");
|
"overview", "analysis", "cv", "cover-letter", "interview",
|
||||||
expect(workspaceSection("match")).toBe("match");
|
]);
|
||||||
|
expect(workspaceSection("match")).toBe("analysis");
|
||||||
|
expect(workspaceSection("timeline")).toBe("overview");
|
||||||
expect(workspaceSection("portfolio")).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
|
// 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.
|
// so the workspace is always usable rather than a shell of placeholders.
|
||||||
export type WorkspaceSectionKey =
|
export type WorkspaceSectionKey = "overview" | "analysis" | "cv" | "cover-letter" | "interview";
|
||||||
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
|
|
||||||
| "documents" | "interview" | "timeline" | "communication";
|
|
||||||
|
|
||||||
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [
|
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [
|
||||||
{ key: "overview", label: "Overview" },
|
{ key: "overview", label: "Overview" },
|
||||||
{ key: "job-details", label: "Job Details" },
|
|
||||||
{ key: "analysis", label: "Analysis" },
|
{ key: "analysis", label: "Analysis" },
|
||||||
{ key: "match", label: "Match" },
|
|
||||||
{ key: "checklist", label: "Checklist" },
|
|
||||||
{ key: "cv", label: "CV" },
|
{ key: "cv", label: "CV" },
|
||||||
{ key: "cover-letter", label: "Cover Letter" },
|
{ key: "cover-letter", label: "Cover Letter" },
|
||||||
{ key: "documents", label: "Documents" },
|
|
||||||
{ key: "interview", label: "Interview Prep" },
|
{ key: "interview", label: "Interview Prep" },
|
||||||
{ key: "timeline", label: "Timeline" },
|
|
||||||
{ key: "communication", label: "Communication" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const workspaceSection = (value: string | null): WorkspaceSectionKey =>
|
const LEGACY_WORKSPACE_SECTIONS: Record<string, WorkspaceSectionKey> = {
|
||||||
WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
|
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 = {
|
export const applicationWorkspaceApi = {
|
||||||
overview: (jobId: number) =>
|
overview: (jobId: number) =>
|
||||||
@@ -158,6 +162,8 @@ export type CareerMatch = {
|
|||||||
relevantProjects: MatchEvidence[];
|
relevantProjects: MatchEvidence[];
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
aiSuggestionCount: number;
|
aiSuggestionCount: number;
|
||||||
|
hasSelectedCv: boolean;
|
||||||
|
selectedCvName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
|
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -262,16 +262,21 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionShell
|
<SectionShell
|
||||||
title="Match"
|
title="CV Match"
|
||||||
subtitle="Your master career profile against this advert. Reads your profile; never changes it."
|
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}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{data && !data.hasCareerProfile ? (
|
{data && !data.hasSelectedCv ? (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
No career profile yet. Matching compares the advert against your master profile — build it
|
Select a CV on the CV tab first. The application will only analyse the document you explicitly link.
|
||||||
once and every application scores against it.
|
</Alert>
|
||||||
|
) : data && !data.hasCareerProfile ? (
|
||||||
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
|
The linked CV cannot be matched until its career profile has content.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
|
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, Divider, IconButton, Paper,
|
||||||
Skeleton, Stack, Tooltip, Typography,
|
Skeleton, Stack, Tab, Tabs, Tooltip, Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
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 AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||||
import ChecklistIcon from "@mui/icons-material/Checklist";
|
import ChecklistIcon from "@mui/icons-material/Checklist";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
import { getApiErrorMessage } from "../api";
|
import { getApiErrorMessage } from "../api";
|
||||||
import Attachments from "../components/Attachments";
|
import Attachments from "../components/Attachments";
|
||||||
import Correspondence from "../components/Correspondence";
|
import Correspondence from "../components/Correspondence";
|
||||||
import AiWorkspacePanel from "../components/AiWorkspacePanel";
|
|
||||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||||
import {
|
import {
|
||||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
|
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
|
||||||
import EditJobDialog from "../components/EditJobDialog";
|
import EditJobDialog from "../components/EditJobDialog";
|
||||||
import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist";
|
import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist";
|
||||||
|
import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import {
|
import {
|
||||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection,
|
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection,
|
||||||
@@ -149,9 +151,9 @@ export function ApplicationWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "220px 1fr" }, gap: 2, alignItems: "start" }}>
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}>
|
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}>
|
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
||||||
<Tooltip title="Back to applications">
|
<Tooltip title="Back to applications">
|
||||||
<IconButton size="small" aria-label="Back to applications" onClick={close}>
|
<IconButton size="small" aria-label="Back to applications" onClick={close}>
|
||||||
<ArrowBackIcon fontSize="small" />
|
<ArrowBackIcon fontSize="small" />
|
||||||
@@ -176,45 +178,30 @@ export function ApplicationWorkspace({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
<List dense component="nav" aria-label="Workspace sections">
|
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
||||||
{WORKSPACE_SECTIONS.map((s) => (
|
<Tabs
|
||||||
<ListItemButton key={s.key} selected={section === s.key} onClick={() => go(s.key)} sx={{ borderRadius: 2 }}>
|
value={section}
|
||||||
<ListItemText
|
onChange={(_, value: WorkspaceSectionKey) => go(value)}
|
||||||
primary={s.label}
|
variant="scrollable"
|
||||||
slotProps={{ primary: { fontSize: 14, fontWeight: section === s.key ? 700 : 500 } }}
|
scrollButtons="auto"
|
||||||
/>
|
aria-label="Workspace sections"
|
||||||
</ListItemButton>
|
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
|
||||||
))}
|
>
|
||||||
</List>
|
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={s.label} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
||||||
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||||
<WorkspaceHeader overview={overview} onEdit={() => setEditOpen(true)} />
|
|
||||||
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
|
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
|
||||||
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />}
|
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
|
||||||
{section === "job-details" && <JobDetailsSection overview={overview} onEdit={() => setEditOpen(true)} />}
|
{section === "analysis" && jobId > 0 && (
|
||||||
{/* Deterministic answer first, then the AI panel below it — the page never generates on load. */}
|
<Stack spacing={2}>
|
||||||
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />}
|
<ApplicationAnalysis jobId={jobId} />
|
||||||
{section === "analysis" && jobId > 0 && <ApplicationStrategySnapshot jobId={jobId} />}
|
<ApplicationMatch jobId={jobId} />
|
||||||
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
|
<ApplicationStrategySnapshot jobId={jobId} />
|
||||||
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
</Stack>
|
||||||
|
)}
|
||||||
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep 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 === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
|
||||||
{section === "cover-letter" && jobId > 0 && (
|
{section === "cover-letter" && jobId > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -226,8 +213,6 @@ export function ApplicationWorkspace({
|
|||||||
onSaved={load}
|
onSaved={load}
|
||||||
onDirtyChange={setPackageDraftsDirty}
|
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>
|
</Box>
|
||||||
@@ -241,9 +226,10 @@ export function ApplicationWorkspace({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
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>;
|
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
|
||||||
return (
|
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}>
|
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
|
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
|
||||||
@@ -257,8 +243,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
|||||||
<EditOutlinedIcon fontSize="small" />
|
<EditOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Chip size="small" label={overview.status} color="primary" variant="outlined" />
|
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
|
||||||
<Chip size="small" label={overview.stageGroup} />
|
|
||||||
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
||||||
{overview.jobUrl && (
|
{overview.jobUrl && (
|
||||||
<Tooltip title="Open original advert">
|
<Tooltip title="Open original advert">
|
||||||
@@ -269,21 +254,59 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</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;
|
overview: WorkspaceOverview | null;
|
||||||
onGo: (s: WorkspaceSectionKey) => void;
|
onGo: (s: WorkspaceSectionKey) => void;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const stats = useMemo(() => overview ? [
|
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: <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: <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: <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]);
|
] : [], [overview]);
|
||||||
|
|
||||||
if (!overview) {
|
if (!overview) {
|
||||||
@@ -342,10 +365,34 @@ function OverviewSection({ overview, onGo, onReload }: {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
|
||||||
</Stack>
|
</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 }) {
|
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||||
if (!overview) return <Skeleton variant="rounded" height={200} />;
|
if (!overview) return <Skeleton variant="rounded" height={200} />;
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user