diff --git a/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs b/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs new file mode 100644 index 0000000..483a31c --- /dev/null +++ b/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs @@ -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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + return (db, new ApplicationWorkspaceService(db)); + } + + private static async Task SeedAsync(JobTrackerContext db, string owner, Action? 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)); + } +} diff --git a/JobTrackerApi/Controllers/ApplicationWorkspaceController.cs b/JobTrackerApi/Controllers/ApplicationWorkspaceController.cs new file mode 100644 index 0000000..55ad699 --- /dev/null +++ b/JobTrackerApi/Controllers/ApplicationWorkspaceController.cs @@ -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 _users; + private readonly IApplicationWorkspaceService _workspace; + + public ApplicationWorkspaceController(UserManager users, IApplicationWorkspaceService workspace) + { + _users = users; + _workspace = workspace; + } + + [HttpGet] + public async Task> 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); + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 4eba639..0e4b907 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -41,6 +41,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/ApplicationWorkspaceService.cs b/JobTrackerApi/Services/ApplicationWorkspaceService.cs new file mode 100644 index 0000000..61636f5 --- /dev/null +++ b/JobTrackerApi/Services/ApplicationWorkspaceService.cs @@ -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 RecentActivity, + WorkspaceNextStepDto? NextStep); + +public interface IApplicationWorkspaceService +{ + Task 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 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); +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md new file mode 100644 index 0000000..53cdefc --- /dev/null +++ b/docs/architecture/application-workspace.md @@ -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 `` (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. diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 3019615..8940f19 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -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 } /> } /> } /> + } /> } /> } /> } /> diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts new file mode 100644 index 0000000..36af0ae --- /dev/null +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -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(`/jobapplications/${jobId}/workspace`).then((r) => r.data), +}; diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index b13dfb1..1704e37 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -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 . + 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, }} > {summaryFirstText} + {/* Phase 5: the full-page Application Workspace. The dialog stays as the quick view. */} + {jobId && onOpenWorkspace ? ( + + ) : null} setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} /> - { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} /> + { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); navigate(`/applications/${id}`); }} /> setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> {statusOptions.map((status) => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })})} diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx new file mode 100644 index 0000000..a1b03e9 --- /dev/null +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -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(null); + const [error, setError] = useState(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 ( + + + {error} + + ); + } + + return ( + + + + + navigate("/jobs")}> + + + + + Workspace + + + + {WORKSPACE_SECTIONS.map((s) => ( + go(s.key)} sx={{ borderRadius: 2 }}> + + + ))} + + + + + + {section === "overview" && } + {section === "job-details" && } + {(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && ( + + + + )} + {section === "documents" && jobId > 0 && ( + + )} + {section === "communication" && jobId > 0 && ( + + )} + {["checklist", "cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && ( + + )} + + + ); +} + +function WorkspaceHeader({ overview }: { overview: WorkspaceOverview | null }) { + if (!overview) return ; + return ( + + + + {overview.jobTitle} + + {[overview.company, overview.location, overview.salary].filter(Boolean).join(" · ") || "—"} + + + + + + {overview.jobUrl && ( + + + + + + )} + + + + ); +} + +function OverviewSection({ overview, onGo, onReload }: { + overview: WorkspaceOverview | null; + onGo: (s: WorkspaceSectionKey) => void; + onReload: () => void; +}) { + const stats = useMemo(() => overview ? [ + { icon: , label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const }, + { icon: , label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const }, + { icon: , label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const }, + { icon: , label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const }, + ] : [], [overview]); + + if (!overview) { + return {[0, 1].map((i) => )}; + } + + return ( + + {overview.nextStep ? ( + + Next recommended action + {overview.nextStep.label} + {overview.nextStep.reason} + + + ) : ( + + Nothing outstanding — this application is fully prepared. + + )} + + + {stats.map((s) => ( + 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 } }}> + + {s.icon} + {s.label} + + {s.value} + + ))} + + + + + Recent activity + + + + {overview.recentActivity.length === 0 ? ( + No activity recorded yet. + ) : ( + + {overview.recentActivity.map((a, i) => ( + + {a.type}{a.detail ? ` — ${a.detail}` : ""} + {new Date(a.at).toLocaleDateString()} + + ))} + + )} + + + ); +} + +function JobDetailsSection({ overview }: { overview: WorkspaceOverview | null }) { + if (!overview) return ; + 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 ( + + Job details + {!overview.hasJobDescription && ( + + No advert text saved. Analysis and matching need it — add it from the application dialog. + + )} + + {rows.map(([k, v]) => ( + + {k} + {v} + + ))} + + + ); +} + +function ComingInMilestone({ section }: { section: string }) { + const m = WORKSPACE_SECTIONS.find((s) => s.key === section); + return ( + + {m?.label} + + Arriving in Phase 5 milestone {m?.milestone}. Existing tools for this remain available in the application dialog. + + + ); +}