feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user