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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 23:57:24 +02:00
parent 3b59152782
commit e55a6e86b7
10 changed files with 797 additions and 2 deletions
@@ -0,0 +1,181 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class ApplicationWorkspaceTests
{
private static (JobTrackerContext db, ApplicationWorkspaceService svc) New(string userId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(s => s.UserId).Returns(userId);
var db = new JobTrackerContext(options, currentUser.Object);
return (db, new ApplicationWorkspaceService(db));
}
private static async Task<JobApplication> SeedAsync(JobTrackerContext db, string owner, Action<JobApplication>? tweak = null)
{
var company = new Company { OwnerUserId = owner, Name = "Acme" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = owner,
CompanyId = company.Id,
JobTitle = "Backend Developer",
Status = "Applied",
Location = "Oslo",
Description = "Needs .NET and SQL.",
DateApplied = DateTime.UtcNow.AddDays(-3),
};
tweak?.Invoke(job);
db.JobApplications.Add(job);
await db.SaveChangesAsync();
return job;
}
[Fact]
public async Task Overview_aggregates_the_application_without_copying_career_data()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.NotNull(o);
Assert.Equal("Backend Developer", o!.JobTitle);
Assert.Equal("Acme", o.Company);
Assert.Equal("Oslo", o.Location);
Assert.True(o.HasJobDescription);
Assert.Null(o.Cv.VariantId); // no variant attached yet
Assert.False(o.HasCoverLetter);
Assert.Equal(0, o.DocumentCount);
}
[Fact]
public async Task Overview_surfaces_the_attached_cv_variant()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
db.CvVariants.Add(new CvVariant
{
OwnerUserId = "user-1",
JobApplicationId = job.Id,
PublicSlug = Guid.NewGuid().ToString("N"),
Name = "Backend CV",
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }),
UpdatedAtUtc = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal("Backend CV", o!.Cv.VariantName);
Assert.Equal("nordic", o.Cv.ThemeId);
}
[Fact]
public async Task Overview_counts_documents_and_ai_history()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "cert.pdf", FilePath = "/tmp/a.pdf", FileType = "PDF" });
db.AiInteractions.Add(new AiInteraction
{
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "job-analysis",
Title = "Job analysis", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal(1, o!.DocumentCount);
Assert.Equal(1, o.AiInteractionCount);
Assert.NotNull(o.LastAiAtUtc);
}
[Fact]
public async Task Overview_returns_recent_activity_newest_first()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-2) });
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Applied", At = DateTime.Now });
await db.SaveChangesAsync();
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal(new[] { "StatusChanged", "Created" }, o!.RecentActivity.Select(a => a.Type));
}
// --- next recommended action: "the user should never ask what to do next" ---
[Fact]
public async Task Next_step_asks_for_the_advert_when_there_is_no_description()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1", j => j.Description = null);
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal("add-job-details", o!.NextStep!.Key);
}
[Fact]
public async Task Next_step_walks_cv_then_cover_letter_then_documents()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
Assert.Equal("prepare-cv", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
job.TailoredCvText = "tailored";
await db.SaveChangesAsync();
Assert.Equal("write-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
job.CoverLetterText = "Dear team";
await db.SaveChangesAsync();
Assert.Equal("attach-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key);
}
[Fact]
public async Task Next_step_prioritises_interview_prep_at_the_interview_stage()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1", j =>
{
j.Status = "Interview";
j.TailoredCvText = "tailored";
j.CoverLetterText = "letter";
});
db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "c.pdf", FilePath = "/tmp/c.pdf", FileType = "PDF" });
await db.SaveChangesAsync();
var o = await svc.GetOverviewAsync("user-1", job.Id, default);
Assert.Equal("prepare-interview", o!.NextStep!.Key);
}
[Fact]
public async Task Another_users_application_is_not_visible()
{
var (db, svc) = New("user-1");
await using var _ = db;
var other = await SeedAsync(db, "user-2");
Assert.Null(await svc.GetOverviewAsync("user-1", other.Id, default));
}
}
@@ -0,0 +1,34 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
// Phase 5 Milestone 1 — one aggregate read for the Application Workspace overview, so the page loads
// from a single call instead of fanning out. Read-only; owns no data.
// docs/architecture/application-workspace.md.
[ApiController]
[Route("api/jobapplications/{jobId:int}/workspace")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class ApplicationWorkspaceController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly IApplicationWorkspaceService _workspace;
public ApplicationWorkspaceController(UserManager<ApplicationUser> users, IApplicationWorkspaceService workspace)
{
_users = users;
_workspace = workspace;
}
[HttpGet]
public async Task<ActionResult<WorkspaceOverviewDto>> GetOverview(int jobId, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var overview = await _workspace.GetOverviewAsync(user.Id, jobId, ct);
return overview is null ? NotFound() : Ok(overview);
}
}
+1
View File
@@ -41,6 +41,7 @@ builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -0,0 +1,156 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Phase 5 Milestone 1 — the Application Workspace overview.
//
// This is an AGGREGATE READ ONLY. It owns no data and duplicates none: the CV comes from the Phase 4
// CvVariant lens, documents from Attachments, activity from JobEvent, AI history from AiInteraction,
// stage semantics from JobPipeline. Nothing here writes, and no career data is copied.
// docs/architecture/application-workspace.md.
public sealed record WorkspaceCvDto(int? VariantId, string? VariantName, string? ThemeId, bool HasTailoredCvText, DateTimeOffset? UpdatedAtUtc);
public sealed record WorkspaceActivityDto(string Type, string? Detail, DateTime At);
public sealed record WorkspaceNextStepDto(string Key, string Label, string Reason, string? Section);
public sealed record WorkspaceOverviewDto(
int Id,
string JobTitle,
string? Company,
string? Location,
string? Salary,
string Status,
string StageGroup,
int StageOrder,
DateTime? DateApplied,
DateTime? Deadline,
DateTime? FollowUpAt,
string? NextAction,
string? JobUrl,
bool HasJobDescription,
WorkspaceCvDto Cv,
bool HasCoverLetter,
int DocumentCount,
bool HasPortfolio,
int AiInteractionCount,
DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
WorkspaceNextStepDto? NextStep);
public interface IApplicationWorkspaceService
{
Task<WorkspaceOverviewDto?> GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
}
public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
{
private const int RecentActivityCount = 8;
private readonly JobTrackerContext _db;
public ApplicationWorkspaceService(JobTrackerContext db)
{
_db = db;
}
public async Task<WorkspaceOverviewDto?> GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
// CV: the most recently touched variant attached to this application (Phase 4 lens).
var variant = await _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId)
.OrderByDescending(v => v.UpdatedAtUtc)
.FirstOrDefaultAsync(ct);
var cv = new WorkspaceCvDto(
variant?.Id,
variant?.Name,
variant is null ? null : CvVariantSettingsJson.Deserialize(variant.SettingsJson).ThemeId,
!string.IsNullOrWhiteSpace(job.TailoredCvText),
variant?.UpdatedAtUtc);
var documentCount = await _db.Attachments.AsNoTracking()
.CountAsync(a => a.JobApplicationId == jobApplicationId, ct);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId, ct);
var lastAi = await _db.AiInteractions.AsNoTracking()
.Where(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId)
.OrderByDescending(a => a.CreatedAtUtc)
.Select(a => (DateTimeOffset?)a.CreatedAtUtc)
.FirstOrDefaultAsync(ct);
var activity = await _db.JobEvents.AsNoTracking()
.Where(e => e.JobApplicationId == jobApplicationId)
.OrderByDescending(e => e.At)
.Take(RecentActivityCount)
.Select(e => new WorkspaceActivityDto(e.Type, e.Note ?? e.NewValue, e.At))
.ToListAsync(ct);
var stage = JobPipeline.Stages.FirstOrDefault(s => string.Equals(s.Key, JobPipeline.Normalize(job.Status), StringComparison.OrdinalIgnoreCase));
var hasCoverLetter = job.HasCoverLetter || !string.IsNullOrWhiteSpace(job.CoverLetterText);
return new WorkspaceOverviewDto(
job.Id,
job.JobTitle,
job.Company?.Name,
job.Location,
job.Salary,
job.Status,
stage?.Group.ToString() ?? "Active",
JobPipeline.OrderOf(job.Status),
job.DateApplied,
job.Deadline,
job.FollowUpAt,
job.NextAction,
job.JobUrl,
!string.IsNullOrWhiteSpace(job.Description),
cv,
hasCoverLetter,
documentCount,
job.HasPortfolio,
aiCount,
lastAi,
activity,
NextStep(job, cv, hasCoverLetter, documentCount));
}
// "The user should never ask what to do next." First unmet rule in priority order wins. Ordered so
// the answer matches where the application actually is: understand the role, prepare the material,
// send it, then chase it.
private static WorkspaceNextStepDto? NextStep(JobApplication job, WorkspaceCvDto cv, bool hasCoverLetter, int documentCount)
{
if (string.IsNullOrWhiteSpace(job.Description))
return new("add-job-details", "Add the job advert", "Analysis and matching need the advert text.", "job-details");
if (JobPipeline.IsProspect(job.Status))
{
if (cv.VariantId is null && !cv.HasTailoredCvText)
return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached yet.", "cv");
if (!hasCoverLetter)
return new("write-cover-letter", "Write a cover letter", "A tailored letter measurably lifts response rates.", "cover-letter");
return new("submit-application", "Submit the application", "The material is ready — move it out of the prospect stage.", "overview");
}
if (cv.VariantId is null && !cv.HasTailoredCvText)
return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached to this application.", "cv");
if (!hasCoverLetter)
return new("write-cover-letter", "Write a cover letter", "No cover letter draft saved for this application.", "cover-letter");
if (documentCount == 0)
return new("attach-documents", "Attach supporting documents", "Certificates or references strengthen the application.", "documents");
if (IsInterviewStage(job.Status))
return new("prepare-interview", "Prepare for the interview", "This application has reached the interview stage.", "interview");
if (job.FollowUpAt is null && job.DateApplied is not null)
return new("schedule-follow-up", "Schedule a follow-up", "Applied with no follow-up date set.", "overview");
if (string.IsNullOrWhiteSpace(job.NextAction))
return new("set-next-action", "Write the next action", "Keeps the application moving deliberately.", "overview");
return null;
}
private static bool IsInterviewStage(string? status) =>
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,94 @@
# Application Workspace (Phase 5)
> Phase 5, Milestone 1 (2026-07-18). The per-application workspace: what it is, what it deliberately
> is not, and how it composes existing systems. Companion to `cv-builder.md`,
> `ai-career-assistant.md`, `career-profile-model.md`.
## What it is
A dedicated surface for one `JobApplication` at `/applications/{id}`, so an application is a place you
work rather than a row you edit in a modal. Job tracking stays the product; the workspace is the
application's home.
**Core principle:** the user should never ask *"what do I do next?"* — the overview always answers it.
## What it is NOT
The workspace **owns no data and duplicates none**. It is an aggregate read plus a navigation shell:
| Section | Backed by (existing system) |
|---|---|
| CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile` |
| Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history |
| Documents | `Attachment` |
| Communication | `Correspondence` |
| Activity / Timeline | `JobEvent` |
| Stage semantics | `JobPipeline` |
No career data is copied into the application. Nothing in this feature writes to the master profile,
a CV variant, or a cover letter.
## Backend
`GET /api/jobapplications/{id}/workspace``WorkspaceOverviewDto`
(`ApplicationWorkspaceController` + `ApplicationWorkspaceService`).
One aggregate read instead of the page fanning out: role/company/location/salary, status + pipeline
group, applied/deadline/follow-up dates, the attached CV variant (id, name, theme), cover-letter
presence, document count, AI interaction count + last run, recent `JobEvent` activity, and the
computed **next recommended action**.
Read-only and tenant-scoped (`OwnerUserId`), returning 404 for another user's application.
### Next recommended action
First unmet rule wins, ordered to match where the application actually is — understand the role,
prepare the material, send it, then chase it:
1. `add-job-details` — no advert text (analysis and matching need it)
2. `prepare-cv` — no CV variant attached and no tailored CV text
3. `write-cover-letter` — no cover letter
4. `attach-documents` — nothing attached
5. `prepare-interview` — the application reached an interview stage
6. `schedule-follow-up` — applied with no follow-up date
7. `set-next-action` — no next action written
Prospect-stage applications short-circuit to CV → cover letter → submit. `null` means nothing is
outstanding.
## Frontend
`ApplicationWorkspacePage` (`/applications/:id`) — a left nav plus a content pane, section selected by
`?section=`, so a section is linkable and survives refresh. Reached from the job dialog's "Open
application workspace" button.
The dialog passes an optional `onOpenWorkspace` callback rather than calling `useNavigate` itself:
`JobDetailsDialog` must stay renderable without a `<Router>` (several suites mount it standalone), so
router context belongs to the caller.
Implemented now: Overview, Job Details, and the sections that reuse an existing component
(Analysis/Match/Interview → `AiWorkspacePanel`, Documents → `Attachments`,
Communication → `Correspondence`). Sections owned by later milestones state their milestone instead of
faking functionality.
## Relationship to `/readiness`
`GET /{id}/readiness` already computes a polish-oriented checklist (score, completed, missing,
reminders) and still backs the dialog's Readiness tab. The workspace's next-action rules are
deliberately narrower and action-shaped. Milestone 2 introduces the persisted, user-editable checklist
and folds the readiness signals into it as defaults — at which point the overlap is resolved in one
place rather than two.
## Extension points
- **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven.
- **New next-action rule**: add one clause to `ApplicationWorkspaceService.NextStep` — ordered, so
position is the priority.
- **More overview data**: extend `WorkspaceOverviewDto`; the page reads one payload.
## Milestones
1. ✅ Workspace foundation — route, nav shell, aggregate overview, next recommended action.
2. Checklist and progress tracking (persisted + custom items).
3. Timeline and activity history. 4. Job analysis. 5. Career matching. 6. CV integration.
7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements.
+2
View File
@@ -50,6 +50,7 @@ const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"))
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
const CvBuilderPage = lazy(() => import("./views/CvBuilderPage"));
const ApplicationWorkspacePage = lazy(() => import("./views/ApplicationWorkspacePage"));
const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor"));
const PublicCvPage = lazy(() => import("./views/PublicCvPage"));
const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage"));
@@ -326,6 +327,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/correspondence/review" element={<GmailReviewPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/applications/:id" element={<ApplicationWorkspacePage />} />
<Route path="/career" element={<CareerWorkspacePage />} />
<Route path="/career/builder" element={<CvBuilderPage />} />
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
@@ -0,0 +1,65 @@
import { api } from "./api";
// Mirrors WorkspaceOverviewDto. Read-only aggregate — the workspace owns no data.
export type WorkspaceCv = {
variantId: number | null;
variantName: string | null;
themeId: string | null;
hasTailoredCvText: boolean;
updatedAtUtc: string | null;
};
export type WorkspaceActivity = { type: string; detail: string | null; at: string };
export type WorkspaceNextStep = { key: string; label: string; reason: string; section: string | null };
export type WorkspaceOverview = {
id: number;
jobTitle: string;
company: string | null;
location: string | null;
salary: string | null;
status: string;
stageGroup: string;
stageOrder: number;
dateApplied: string | null;
deadline: string | null;
followUpAt: string | null;
nextAction: string | null;
jobUrl: string | null;
hasJobDescription: boolean;
cv: WorkspaceCv;
hasCoverLetter: boolean;
documentCount: number;
hasPortfolio: boolean;
aiInteractionCount: number;
lastAiAtUtc: string | null;
recentActivity: WorkspaceActivity[];
nextStep: WorkspaceNextStep | null;
};
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
// so the workspace is always usable rather than a shell of placeholders.
export type WorkspaceSectionKey =
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
| "portfolio" | "documents" | "interview" | "timeline" | "notes" | "communication";
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; milestone?: number }[] = [
{ key: "overview", label: "Overview" },
{ key: "job-details", label: "Job Details" },
{ key: "analysis", label: "Analysis" },
{ key: "match", label: "Match" },
{ key: "checklist", label: "Checklist", milestone: 2 },
{ key: "cv", label: "CV", milestone: 6 },
{ key: "cover-letter", label: "Cover Letter", milestone: 7 },
{ key: "portfolio", label: "Portfolio", milestone: 8 },
{ key: "documents", label: "Documents" },
{ key: "interview", label: "Interview Prep" },
{ key: "timeline", label: "Timeline", milestone: 3 },
{ key: "notes", label: "Notes", milestone: 3 },
{ key: "communication", label: "Communication" },
];
export const applicationWorkspaceApi = {
overview: (jobId: number) =>
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
};
@@ -50,6 +50,9 @@ interface Props {
onClose: () => void;
initialTab?: number;
initialFollowUpMode?: string;
// Supplied by callers that live inside the router. Optional on purpose: the dialog must not depend
// on router context, so it stays renderable standalone (and in tests) without a <Router>.
onOpenWorkspace?: (jobId: number) => void;
}
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
@@ -128,7 +131,7 @@ function serializeTailoredDraft(draft: TailoredCvDraft) {
});
}
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode, onOpenWorkspace }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
@@ -680,6 +683,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
{/* Phase 5: the full-page Application Workspace. The dialog stays as the quick view. */}
{jobId && onOpenWorkspace ? (
<Button size="small" variant="outlined" sx={{ mt: 1.5 }} onClick={() => onOpenWorkspace(jobId)}>
Open application workspace
</Button>
) : null}
</Box>
<Tabs
value={tab}
+1 -1
View File
@@ -776,7 +776,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} />
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); navigate(`/applications/${id}`); }} />
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })}</MenuItem>)}
@@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
Skeleton, Stack, Tooltip, Typography,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { getApiErrorMessage } from "../api";
import Attachments from "../components/Attachments";
import Correspondence from "../components/Correspondence";
import AiWorkspacePanel from "../components/AiWorkspacePanel";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
} from "../applicationWorkspace";
// Phase 5 Milestone 1 — the dedicated Application Workspace.
//
// This is a surface, not a new data store: the overview is one aggregate read, and each section
// reuses the component that already owns that domain (Attachments, Correspondence, AiWorkspacePanel).
// Sections belonging to later milestones say so rather than pretending to work.
// docs/architecture/application-workspace.md.
export default function ApplicationWorkspacePage() {
const { id } = useParams();
const jobId = Number(id);
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const section = (params.get("section") as WorkspaceSectionKey) || "overview";
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setOverview(await applicationWorkspaceApi.overview(jobId));
} catch (err) {
setError(getApiErrorMessage(err, "Could not open this application."));
}
}, [jobId]);
useEffect(() => {
load();
}, [load]);
const go = (next: WorkspaceSectionKey) => setParams({ section: next }, { replace: true });
if (error) {
return (
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/jobs")}>Back to applications</Button>
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
</Box>
);
}
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "220px 1fr" }, gap: 2, alignItems: "start" }}>
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}>
<Tooltip title="Back to applications">
<IconButton size="small" aria-label="Back to applications" onClick={() => navigate("/jobs")}>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
Workspace
</Typography>
</Stack>
<List dense component="nav" aria-label="Workspace sections">
{WORKSPACE_SECTIONS.map((s) => (
<ListItemButton key={s.key} selected={section === s.key} onClick={() => go(s.key)} sx={{ borderRadius: 2 }}>
<ListItemText
primary={s.label}
secondary={s.milestone ? `Milestone ${s.milestone}` : undefined}
slotProps={{ primary: { fontSize: 14, fontWeight: section === s.key ? 700 : 500 }, secondary: { fontSize: 11 } }}
/>
</ListItemButton>
))}
</List>
</Paper>
<Box sx={{ display: "grid", gap: 2 }}>
<WorkspaceHeader overview={overview} />
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} />}
{section === "job-details" && <JobDetailsSection overview={overview} />}
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<AiWorkspacePanel jobId={jobId} />
</Paper>
)}
{section === "documents" && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}><Attachments jobId={jobId} /></Paper>
)}
{section === "communication" && jobId > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}><Correspondence jobId={jobId} job={null as any} /></Paper>
)}
{["checklist", "cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && (
<ComingInMilestone section={section} />
)}
</Box>
</Box>
);
}
function WorkspaceHeader({ overview }: { overview: WorkspaceOverview | null }) {
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
return (
<Paper sx={{ p: 2.5, borderRadius: 3 }}>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
<Typography color="text.secondary">
{[overview.company, overview.location, overview.salary].filter(Boolean).join(" · ") || "—"}
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Chip size="small" label={overview.status} color="primary" variant="outlined" />
<Chip size="small" label={overview.stageGroup} />
{overview.jobUrl && (
<Tooltip title="Open original advert">
<IconButton size="small" aria-label="Open original advert" href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</Stack>
</Paper>
);
}
function OverviewSection({ overview, onGo, onReload }: {
overview: WorkspaceOverview | null;
onGo: (s: WorkspaceSectionKey) => void;
onReload: () => void;
}) {
const stats = useMemo(() => overview ? [
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
{ icon: <FolderOutlinedIcon fontSize="small" />, label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const },
{ icon: <AutoFixHighIcon fontSize="small" />, label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const },
] : [], [overview]);
if (!overview) {
return <Stack spacing={2}>{[0, 1].map((i) => <Skeleton key={i} variant="rounded" height={120} />)}</Stack>;
}
return (
<Stack spacing={2}>
{overview.nextStep ? (
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
<Typography variant="overline" color="text.secondary">Next recommended action</Typography>
<Typography variant="h6" sx={{ fontWeight: 800 }}>{overview.nextStep.label}</Typography>
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{overview.nextStep.reason}</Typography>
<Button variant="contained" endIcon={<ArrowForwardIcon />}
onClick={() => overview.nextStep?.section && onGo(overview.nextStep.section as WorkspaceSectionKey)}>
{overview.nextStep.label}
</Button>
</Paper>
) : (
<Alert severity="success" sx={{ borderRadius: 3 }}>
Nothing outstanding this application is fully prepared.
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(4, 1fr)" }, gap: 1.5 }}>
{stats.map((s) => (
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
onClick={() => onGo(s.go)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onGo(s.go); } }}
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", "&:focus-visible": { boxShadow: 3 } }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.disabled" }}>
{s.icon}
<Typography variant="caption" sx={{ fontWeight: 700 }}>{s.label}</Typography>
</Stack>
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>{s.value}</Typography>
</Paper>
))}
</Box>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Recent activity</Typography>
<Button size="small" onClick={onReload}>Refresh</Button>
</Stack>
<Divider sx={{ my: 1 }} />
{overview.recentActivity.length === 0 ? (
<Typography variant="body2" color="text.secondary">No activity recorded yet.</Typography>
) : (
<Stack spacing={0.75}>
{overview.recentActivity.map((a, i) => (
<Stack key={i} direction="row" spacing={1} justifyContent="space-between">
<Typography variant="body2"><strong>{a.type}</strong>{a.detail ? `${a.detail}` : ""}</Typography>
<Typography variant="caption" color="text.secondary">{new Date(a.at).toLocaleDateString()}</Typography>
</Stack>
))}
</Stack>
)}
</Paper>
</Stack>
);
}
function JobDetailsSection({ overview }: { overview: WorkspaceOverview | null }) {
if (!overview) return <Skeleton variant="rounded" height={200} />;
const rows: [string, string][] = [
["Company", overview.company ?? "—"],
["Location", overview.location ?? "—"],
["Salary", overview.salary ?? "—"],
["Status", overview.status],
["Applied", overview.dateApplied ? new Date(overview.dateApplied).toLocaleDateString() : "—"],
["Deadline", overview.deadline ? new Date(overview.deadline).toLocaleDateString() : "—"],
["Follow-up", overview.followUpAt ? new Date(overview.followUpAt).toLocaleDateString() : "Not scheduled"],
["Next action", overview.nextAction ?? "—"],
];
return (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Job details</Typography>
{!overview.hasJobDescription && (
<Alert severity="warning" sx={{ mb: 1.5, borderRadius: 2 }}>
No advert text saved. Analysis and matching need it add it from the application dialog.
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "160px 1fr" }, rowGap: 0.75, columnGap: 2 }}>
{rows.map(([k, v]) => (
<React.Fragment key={k}>
<Typography variant="body2" color="text.secondary">{k}</Typography>
<Typography variant="body2">{v}</Typography>
</React.Fragment>
))}
</Box>
</Paper>
);
}
function ComingInMilestone({ section }: { section: string }) {
const m = WORKSPACE_SECTIONS.find((s) => s.key === section);
return (
<Paper sx={{ p: 3, borderRadius: 3, textAlign: "center" }}>
<Typography sx={{ fontWeight: 700 }}>{m?.label}</Typography>
<Typography color="text.secondary">
Arriving in Phase 5 milestone {m?.milestone}. Existing tools for this remain available in the application dialog.
</Typography>
</Paper>
);
}