feat(ui): redesign job workspace sections

This commit is contained in:
cesnimda
2026-10-07 01:58:31 +02:00
parent acd1d94684
commit ac6d78289d
123 changed files with 2842 additions and 205 deletions
+25 -9
View File
@@ -3,7 +3,7 @@ import "@testing-library/jest-dom";
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import {
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection,
} from "./components/ApplicationAssets";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
@@ -74,6 +74,22 @@ beforeEach(() => {
afterEach(() => window.localStorage.removeItem("uiLanguage"));
test("document package history is read-only and separates captured notes from answers", async () => {
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: false, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [] };
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/91') ? {
summary, renderedCvHtml: null, coverLetterText: "Frozen letter", applicationMaterialJson: JSON.stringify({ Notes: "Human notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nFrozen answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>" }),
} : [summary] } as any));
render(<SubmittedPackagesSection jobId={7} allowCapture={false} />);
fireEvent.click(await screen.findByRole("button", { name: "View preserved package" }));
expect(await screen.findByRole("dialog")).toHaveTextContent("immutable submission snapshot");
expect(screen.getByText("Human notes", { exact: true })).toBeInTheDocument();
expect(screen.getByText("Frozen answer", { exact: true })).toBeInTheDocument();
expect(screen.queryByText(/<<<APPLICATION/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Freeze current package" })).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
// ---------- CV ----------
test("cv section shows the attached variant and the ones available to attach", async () => {
@@ -161,7 +177,7 @@ test("editing marks the draft dirty and saving sends the new text", async () =>
const onDirtyChange = jest.fn();
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } });
fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "Dear hiring team" } });
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
@@ -177,7 +193,7 @@ test("discarding returns to the saved text", async () => {
routeGet();
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "scratch" } });
fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "scratch" } });
fireEvent.click(screen.getByRole("button", { name: /Discard changes/i }));
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
@@ -202,7 +218,7 @@ test("an empty cover letter offers the template and an empty history", async ()
expect(await screen.findByText(/No versions yet/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Start from template/i }));
expect((screen.getByLabelText("Cover letter") as HTMLTextAreaElement).value)
expect((screen.getByRole("textbox", { name: "Cover letter" }) as HTMLTextAreaElement).value)
.toContain("Dear Hiring Manager");
});
@@ -213,7 +229,7 @@ test("the empty cover-letter template and rich-text controls follow Bokmål UI l
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: /start fra mal/i }));
expect((screen.getByLabelText("Søknadsbrev") as HTMLTextAreaElement).value)
expect((screen.getByRole("textbox", { name: "Søknadsbrev" }) as HTMLTextAreaElement).value)
.toContain("Kjære rekrutteringsansvarlig");
expect(screen.getByRole("button", { name: "Fet" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument();
@@ -250,9 +266,9 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
action: "generate",
}),
));
expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team");
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team");
fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i }));
expect(screen.getByLabelText("Cover letter")).toHaveValue("A tailored, truthful suggestion.");
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion.");
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -272,10 +288,10 @@ test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without
expect(screen.queryByText(/\{name\}/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Generate" }));
await screen.findByText("Unaccepted letter");
expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team");
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team");
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.queryByText("Unaccepted letter")).not.toBeInTheDocument();
expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team");
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team");
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(onDirtyChange).not.toHaveBeenCalledWith(true);
expect(mockedApi.put).not.toHaveBeenCalled();
@@ -86,7 +86,8 @@ test("timeline renders grouped days, milestones and readable summaries", async (
render(<ApplicationTimeline jobId={7} />);
expect(await screen.findByText("Milestones")).toBeInTheDocument();
expect(screen.getByText("Today")).toBeInTheDocument();
expect(screen.getByText(new Date(`${timeline.days[0].date}T12:00:00`).toLocaleDateString("en", { dateStyle: "long" }))).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveTextContent("AI suggestions refreshed");
expect(screen.getAllByText("Moved from Applied to Interview").length).toBeGreaterThan(0);
expect(screen.getByText("AI suggestions refreshed")).toBeInTheDocument();
});
@@ -146,9 +147,9 @@ test("analysis prompts for the advert when there is none", async () => {
test("analysis shows a loading state before the data arrives", () => {
mockedApi.get.mockReturnValue(new Promise(() => {}) as any);
const { container } = render(<ApplicationAnalysis jobId={7} />);
render(<ApplicationAnalysis jobId={7} />);
expect(container.querySelectorAll(".MuiSkeleton-root").length).toBeGreaterThan(0);
expect(screen.getByRole("progressbar", { name: "Loading..." })).toBeInTheDocument();
});
// ---------- Match ----------
@@ -27,7 +27,8 @@ jest.mock("./components/ApplicationAssets", () => ({
</div>
),
ApplicationCvSection: () => <div>CV section</div>,
ApplicationPackageDraftsSection: () => <div>Application drafts section</div>,
ApplicationPackageDraftsSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
SubmittedPackagesSection: () => <div>Frozen packages section</div>,
}));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
jest.mock("./components/ApplicationWorkflowAssist", () => ({
@@ -174,10 +175,10 @@ test("opens the dedicated workspace from the whole row and preserves list state
expect(await screen.findByText("Backend Developer")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42");
fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis");
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match");
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend"));
@@ -190,7 +191,7 @@ test("opens a direct workspace URL and returns to applications", async () => {
expect(await screen.findByText("Analysis section")).toBeInTheDocument();
expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Analysis" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Match" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText(/application progress: waiting/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
@@ -219,16 +220,16 @@ test("warns before section navigation would discard application edits", async ()
renderTable("/jobs/42?section=cover-letter");
fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" }));
fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Keep editing" }));
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter");
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis"));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match"));
});
test("renders shared workspace navigation and progress in Norwegian Bokmål", async () => {
@@ -248,8 +249,8 @@ test("renders shared workspace navigation and progress in Norwegian Bokmål", as
renderTable("/jobs/42");
expect(await screen.findByRole("tab", { name: "Oversikt" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Analyse" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Søknadsbrev" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Samsvar" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Søknad" })).toBeInTheDocument();
expect(await screen.findByLabelText(/søknadsprogresjon/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /tilbake til søknader/i })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Forbered en CV for denne stillingen" })).toBeInTheDocument();
@@ -323,17 +324,17 @@ test("uses recorded dates in the summary and keeps absent dates unknown", async
test("section changes retain follow-up query context and expose the selected labelled panel", async () => {
renderTable("/jobs/42?section=communication&followMode=waiting-update");
await screen.findByRole("heading", { name: "Backend Developer" });
fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
expect(await screen.findByRole("tabpanel", { name: "Analysis" })).toHaveTextContent("Match section");
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis&followMode=waiting-update");
const tab = screen.getByRole("tab", { name: "Analysis" });
expect(document.getElementById(tab.getAttribute("aria-controls")!)).toBe(screen.getByRole("tabpanel", { name: "Analysis" }));
fireEvent.click(screen.getByRole("tab", { name: "Match" }));
expect(await screen.findByRole("tabpanel", { name: "Match" })).toHaveTextContent("Match section");
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match&followMode=waiting-update");
const tab = screen.getByRole("tab", { name: "Match" });
expect(document.getElementById(tab.getAttribute("aria-controls")!)).toBe(screen.getByRole("tabpanel", { name: "Match" }));
});
test("accordion controls identify exactly one labelled content region", async () => {
renderTable("/jobs/42");
await screen.findByRole("heading", { name: "Backend Developer" });
const trigger = screen.getByRole("button", { name: "Documents" });
const trigger = screen.getByRole("button", { name: "Job details" });
fireEvent.click(trigger);
const target = trigger.getAttribute("aria-controls")!;
expect(document.querySelectorAll(`[id='${target}']`)).toHaveLength(1);
@@ -341,12 +342,11 @@ test("accordion controls identify exactly one labelled content region", async ()
expect(document.getElementById(target)).toHaveAttribute("aria-labelledby", trigger.id);
});
test("legacy document links reveal their existing content and focus the disclosure", async () => {
test("legacy document links reveal current attachments and frozen history in the named section", async () => {
renderTable("/jobs/42?section=documents");
const trigger = await screen.findByRole("button", { name: "Documents" });
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
expect(trigger).toHaveFocus();
expect(screen.getByRole("region", { name: "Documents" })).toHaveTextContent("Documents section");
expect(await screen.findByRole("tab", { name: "Documents" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "Documents" })).toHaveTextContent("Documents section");
expect(screen.getByRole("tabpanel", { name: "Documents" })).toHaveTextContent("Frozen packages section");
});
test("the missing-profile next task opens the existing canonical profile entry", async () => {
@@ -357,3 +357,29 @@ test("the missing-profile next task opens the existing canonical profile entry",
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("application subsection navigation retains metadata and protects unsaved answers", async () => {
renderTable("/jobs/42?section=application&sub=answers&followMode=waiting-update");
fireEvent.click(await screen.findByRole("button", { name: "Make answers dirty" }));
fireEvent.click(screen.getByRole("tab", { name: "Submission" }));
fireEvent.click(await screen.findByRole("button", { name: "Keep editing" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("tabpanel", { name: "Answers & message draft" })).toHaveTextContent("Application drafts section");
fireEvent.click(screen.getByRole("tab", { name: "Submission" }));
fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" }));
expect(await screen.findByRole("tabpanel", { name: "Submission" })).toHaveTextContent("Frozen packages section");
expect(screen.getByTestId("location")).toHaveTextContent("section=application&sub=submission&followMode=waiting-update");
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("legacy correspondence and notes reveal distinct domain content", async () => {
mockedApi.get.mockResolvedValue({ data: { ...overview, notes: "Human notes, not application answers" } } as any);
renderTable("/jobs/42?section=communication&followMode=waiting-update");
expect(await screen.findByRole("tabpanel", { name: "Communication" })).toHaveTextContent("Communication section");
fireEvent.click(screen.getByRole("tab", { name: "Notes & tasks" }));
expect(await screen.findByRole("region", { name: "Notes" })).toHaveTextContent("Human notes, not application answers");
expect(screen.getByRole("tabpanel", { name: "Notes & tasks" })).toHaveTextContent("Checklist section");
expect(screen.queryByText("Application drafts section")).not.toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("followMode=waiting-update");
});
@@ -1,15 +1,34 @@
import { WORKSPACE_SECTIONS, workspaceSection } from "./applicationWorkspace";
import { WORKSPACE_SECTIONS, workspaceSection, workspaceSubsection } from "./applicationWorkspace";
test("workspace navigation exposes five purposes and preserves legacy links", () => {
test("workspace navigation exposes seven purposes and preserves legacy links", () => {
expect(WORKSPACE_SECTIONS.map((section) => section.key)).toEqual([
"overview", "analysis", "cv", "cover-letter", "interview",
"overview", "match", "application", "documents", "timeline", "tasks", "interview",
]);
expect(workspaceSection("match")).toBe("analysis");
expect(workspaceSection("timeline")).toBe("overview");
expect(workspaceSection("portfolio")).toBe("overview");
expect(workspaceSection("notes")).toBe("cover-letter");
for (const alias of ["job-details", "checklist", "documents", "communication"]) expect(workspaceSection(alias)).toBe("overview");
expect(workspaceSection("analysis")).toBe("match");
expect(workspaceSection("portfolio")).toBe("documents");
expect(workspaceSection("notes")).toBe("tasks");
expect(workspaceSection("checklist")).toBe("tasks");
expect(workspaceSection("communication")).toBe("timeline");
expect(workspaceSection("cv")).toBe("application");
expect(workspaceSection("cover-letter")).toBe("application");
expect(workspaceSection("job-details")).toBe("overview");
expect(workspaceSection("unknown-section")).toBe("overview");
expect(workspaceSection("constructor")).toBe("overview");
expect(workspaceSection("__proto__")).toBe("overview");
});
test("legacy material and correspondence links reveal their original owner without rewriting URLs", () => {
expect(workspaceSubsection("cv", "submission")).toBe("cv");
expect(workspaceSubsection("cover-letter", null)).toBe("letter");
expect(workspaceSubsection("communication", "activity")).toBe("correspondence");
expect(workspaceSubsection("timeline", null)).toBe("activity");
});
test("canonical subsection URLs are durable and unknown inputs have safe defaults", () => {
for (const sub of ["cv", "letter", "answers", "submission"]) expect(workspaceSubsection("application", sub)).toBe(sub);
expect(workspaceSubsection("timeline", "correspondence")).toBe("correspondence");
for (const sub of [null, "unknown", "constructor", "__proto__"]) {
expect(workspaceSubsection("application", sub)).toBe("cv");
expect(workspaceSubsection("timeline", sub)).toBe("activity");
}
});
+23 -13
View File
@@ -77,15 +77,16 @@ export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [
{ key: "custom", label: "Your own tasks" },
];
// 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" | "analysis" | "cv" | "cover-letter" | "interview";
// URL-addressable presentation; domain components remain the data owners.
export type WorkspaceSectionKey = "overview" | "match" | "application" | "documents" | "timeline" | "tasks" | "interview";
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [
{ key: "overview", label: "Overview" },
{ key: "analysis", label: "Analysis" },
{ key: "cv", label: "CV" },
{ key: "cover-letter", label: "Cover Letter" },
{ key: "match", label: "Match" },
{ key: "application", label: "Application" },
{ key: "documents", label: "Documents" },
{ key: "timeline", label: "Timeline" },
{ key: "tasks", label: "Notes & tasks" },
{ key: "interview", label: "Interview Prep" },
];
@@ -95,14 +96,14 @@ export function checklistSystemTranslationToken(systemKey?: string | null): stri
}
const LEGACY_WORKSPACE_SECTIONS: Record<string, WorkspaceSectionKey> = {
match: "analysis",
analysis: "match",
cv: "application",
"cover-letter": "application",
"job-details": "overview",
checklist: "overview",
documents: "overview",
timeline: "overview",
communication: "overview",
portfolio: "overview",
notes: "cover-letter",
checklist: "tasks",
communication: "timeline",
portfolio: "documents",
notes: "tasks",
};
/** Preserve old shared/bookmarked workspace links while keeping one clear primary navigation. */
@@ -111,6 +112,15 @@ export const workspaceSection = (value: string | null): WorkspaceSectionKey => {
return WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
};
/** Legacy links select their original content, even when unrelated sub metadata is present. */
export function workspaceSubsection(section: string | null, sub: string | null): string {
if (section === "cv") return "cv";
if (section === "cover-letter") return "letter";
if (section === "communication") return "correspondence";
if (workspaceSection(section) === "application") return ["cv", "letter", "answers", "submission"].includes(sub ?? "") ? sub! : "cv";
return sub === "correspondence" ? "correspondence" : "activity";
}
export const applicationWorkspaceApi = {
overview: (jobId: number) =>
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
+17 -1
View File
@@ -9,6 +9,7 @@ import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
jest.mock('./api', () => ({
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
api: {
get: jest.fn(),
post: jest.fn(),
@@ -19,6 +20,8 @@ jest.mock('./api', () => ({
const mockedApi = api as jest.Mocked<typeof api>;
beforeEach(() => jest.clearAllMocks());
test('attachments empty state renders drag and drop guidance', async () => {
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
@@ -35,7 +38,7 @@ test('attachments empty state renders drag and drop guidance', async () => {
);
expect(await screen.findByText(/drag and drop files here/i)).toBeInTheDocument();
expect(screen.getByText(/no attachments yet/i)).toBeInTheDocument();
expect(await screen.findByText(/no attachments yet/i)).toBeInTheDocument();
expect(screen.getByText(/max 10 mb each/i)).toBeInTheDocument();
});
@@ -72,6 +75,8 @@ test('attachments metadata controls update purpose and ai usage', async () => {
expect(screen.getByRole('button', { name: 'Download: resume.pdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Rename: resume.pdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete: resume.pdf' })).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(8);
expect(screen.getByRole('columnheader', { name: 'Uploaded' })).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole('combobox', { name: /purpose/i }));
fireEvent.click(await screen.findByRole('option', { name: /portfolio/i }));
@@ -86,3 +91,14 @@ test('attachments metadata controls update purpose and ai usage', async () => {
expect(mockedApi.patch).toHaveBeenCalledWith('/attachments/7', expect.objectContaining({ purpose: 'portfolio', useForAi: false }));
});
});
test('attachment failures remain explicit and retry restores the real empty state', async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: [] } as any);
render(<ToastProvider><I18nProvider><ConfirmProvider><PromptProvider><Attachments jobId={42} /></PromptProvider></ConfirmProvider></I18nProvider></ToastProvider>);
expect(await screen.findByRole('alert')).toHaveTextContent("Could not load this application's attachments.");
expect(screen.queryByText(/no attachments yet/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByText(/no attachments yet/i)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.post).not.toHaveBeenCalled();
});
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useId, useState } from "react";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Skeleton, Stack, TextField, Tooltip, Typography,
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import RichTextField from "./RichTextField";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
@@ -19,6 +19,9 @@ import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../applicationDrafts";
// Phase 5.4 — Application Assets sections for the workspace.
//
@@ -70,24 +73,16 @@ function Shell({ title, subtitle, loading, error, children }: {
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
</PageSection>
);
}
// ---------- CV ----------
export function ApplicationCvSection({ jobId }: { jobId: number }) {
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
@@ -166,7 +161,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
{data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ minWidth: 0, maxWidth: "100%" }}>
<Button
size="small"
variant="outlined"
@@ -198,12 +193,14 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
</Shell>
<ApplicationTailoringSection jobId={jobId} />
<SubmittedPackagesSection jobId={jobId} />
{includePackages && <SubmittedPackagesSection jobId={jobId} />}
</Stack>
);
}
function SubmittedPackagesSection({ jobId }: { jobId: number }) {
export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId: number; allowCapture?: boolean }) {
const titleId = useId();
const viewTrigger = useRef<HTMLButtonElement | null>(null);
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
@@ -260,31 +257,31 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Stack spacing={1.5}>
<Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>
</Button>}
{(data?.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">{t("assetsNoSubmittedPackages")}</Typography>
) : data?.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
<Stack spacing={1} sx={{ minWidth: 0 }}>
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="flex-start" gap={1}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("assetsSubmittedPackageVersion", { version: item.version })}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(item.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
</Typography>
</Box>
<Stack direction="row" gap={0.5} flexWrap="wrap" justifyContent="flex-end">
<Stack direction="row" gap={0.5} flexWrap="wrap" sx={{ minWidth: 0, maxWidth: "100%", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Chip size="small" variant="outlined" color={item.hasCv ? "success" : "default"} label={item.hasCv ? `${item.cvVariantName ?? t("workspaceCv")} · v${item.cvVariantVersion ?? "—"}` : t("assetsPackageNoCv")} />
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={() => void view(item)} sx={{ alignSelf: "flex-start" }}>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start" }}>
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
@@ -292,15 +289,17 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
</Paper>
))}
</Stack>
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby="submitted-package-title">
<DialogTitle id="submitted-package-title">
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby={titleId} slotProps={{ transition: { onExited: () => viewTrigger.current?.focus() } }}>
<DialogTitle id={titleId}>
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<Alert severity="info" sx={{ mb: 2 }}>{t("workspaceFrozenHelp")}</Alert>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, overflowWrap: "anywhere" }}>{detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
@@ -310,12 +309,19 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft", "Notes"].map((key) => typeof material[key] === "string" && material[key] ? (
{material && ["TailoredCvText", "RecruiterMessageDraft"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
{material && typeof material.Notes === "string" && [
[t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)],
[t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)],
].map(([label, content]) => content ? <Box key={label}>
<Typography variant="subtitle2">{label}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{content}</Typography>
</Box> : null)}
</Stack>
</DialogContent>
<DialogActions><Button onClick={() => setDetail(null)}>{t("close")}</Button></DialogActions>
@@ -10,6 +10,7 @@ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
import {
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, checklistSystemTranslationToken,
} from "../applicationWorkspace";
@@ -98,25 +99,18 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{progress && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("checklistTitle")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("checklistProgress", { completed: progress.completed, total: progress.total })}
</Typography>
</Stack>
<PageSection surface title={t("checklistTitle")} level="h3" description={t("checklistProgress", { completed: progress.completed, total: progress.total })}>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label={t("checklistCompletion")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
</PageSection>
)}
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{checklistCategoryLabel(t, group.key)}</Typography>
<PageSection key={group.key} surface title={checklistCategoryLabel(t, group.key)} level="h3">
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => {
const display = checklistItemDisplay(t, item);
@@ -143,7 +137,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
sx={{
fontWeight: 600,
textDecoration: item.status === "done" ? "line-through" : "none",
color: item.status === "done" ? "text.disabled" : "text.primary",
color: item.status === "done" ? "text.secondary" : "text.primary",
}}
>
{display.title}
@@ -182,11 +176,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
);
})}
</Stack>
</Paper>
</PageSection>
))}
<Paper component="form" onSubmit={add} sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" spacing={1}>
<Paper variant="outlined" component="form" onSubmit={add} sx={{ p: 2, borderRadius: 1 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
fullWidth
size="small"
@@ -195,7 +189,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>{t("checklistAdd")}</Button>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()} sx={{ flexShrink: 0, whiteSpace: "nowrap" }}>{t("checklistAdd")}</Button>
</Stack>
</Paper>
</Stack>
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Chip, Divider, LinearProgress, Paper, Skeleton, Stack, ToggleButton,
Alert, Box, Chip, LinearProgress, Stack, ToggleButton,
ToggleButtonGroup, Typography,
} from "@mui/material";
@@ -10,6 +10,8 @@ import {
CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi,
} from "../applicationWorkspace";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
// Phase 5.3 — Application Intelligence sections for the workspace.
//
@@ -62,20 +64,14 @@ function SectionShell({ title, subtitle, loading, error, empty, emptyText, child
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={40} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : empty ? (
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && (empty ? (
<Typography variant="body2" color="text.secondary">{emptyText}</Typography>
) : (
children
)}
</Paper>
))}
</PageSection>
);
}
@@ -123,11 +119,10 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
return (
<Stack spacing={2}>
{hasEvents && (data?.milestones.length ?? 0) > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("intelligenceMilestones")}</Typography>
<PageSection surface title={t("intelligenceMilestones")} level="h3">
<Stack spacing={0.75}>
{(data?.milestones ?? []).map((m) => (
<Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline">
<Stack key={m.id} direction={{ xs: "column", sm: "row" }} spacing={0.5} justifyContent="space-between" alignItems="baseline">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{m.summary}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(m.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}
@@ -135,7 +130,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
</Stack>
))}
</Stack>
</Paper>
</PageSection>
)}
<SectionShell
@@ -173,11 +168,12 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
(data?.days ?? []).map((day) => (
<Box key={day.date}>
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
{day.label}
{new Date(`${day.date.slice(0, 10)}T12:00:00`).toLocaleDateString(language === "nb" ? "nb-NO" : "en", { dateStyle: "long" })}
</Typography>
<Stack spacing={1} sx={{ mt: 0.75, pl: 1.5, borderLeft: "2px solid", borderColor: "divider" }}>
<Stack component="ol" spacing={1.5} sx={{ mt: 0.75, mb: 0, pl: 1.5, listStyle: "none", borderLeft: "2px solid", borderColor: "divider" }}>
{day.events.map((e) => (
<Box key={e.id} sx={{ pl: 1.5 }}>
<Box component="li" key={e.id} sx={{ pl: 1, overflowWrap: "anywhere" }}>
<Typography component="time" dateTime={e.at} variant="caption" color="text.secondary">{new Date(e.at).toLocaleTimeString(language === "nb" ? "nb-NO" : "en", { hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short" })} · {timelineCategoryLabel(t, e.category)}</Typography>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}>
{e.summary}
+20 -20
View File
@@ -35,6 +35,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useDialogActions } from "../dialogs";
import ViewStateNotice from "./ViewStateNotice";
interface AttachmentItem {
id: number;
@@ -85,9 +86,11 @@ function purposeLabel(purpose: string | null | undefined, t: (key: any) => strin
export default function Attachments({ jobId }: { jobId: number }) {
const labelId = useId();
const { toast } = useToast();
const { t } = useI18n();
const { language, t } = useI18n();
const { confirmAction, promptForValue } = useDialogActions();
const [items, setItems] = useState<AttachmentItem[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [previewOpen, setPreviewOpen] = useState(false);
const [preview, setPreview] = useState<{ url: string; type: string; name: string } | null>(null);
const [dragActive, setDragActive] = useState(false);
@@ -95,13 +98,17 @@ export default function Attachments({ jobId }: { jobId: number }) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<AttachmentItem[]>(`/attachments/${jobId}`);
setItems(res.data);
} catch {
// non-fatal
setLoadError(null);
} catch (error) {
setLoadError(getApiErrorMessage(error, t("attachmentsLoadFailed")));
} finally {
setLoading(false);
}
}, [jobId]);
}, [jobId, t]);
useEffect(() => {
void load();
@@ -253,11 +260,10 @@ export default function Attachments({ jobId }: { jobId: number }) {
sx={{
mb: 2,
p: 2,
borderRadius: 3,
borderRadius: 1,
border: "1px dashed",
borderColor: dragActive ? "primary.main" : "divider",
backgroundColor: dragActive ? "action.hover" : "background.paper",
transition: "all 0.2s ease",
textAlign: "center",
}}
>
@@ -268,14 +274,17 @@ export default function Attachments({ jobId }: { jobId: number }) {
{uploading ? <LinearProgress sx={{ mt: 1.5, borderRadius: 999 }} /> : null}
</Box>
<TableContainer sx={{ borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Table size="small">
<ViewStateNotice title={t("attachmentsLoadFailed")} loading={loading} error={loadError ? { kind: "error", message: loadError, retryable: true } : null} onRetry={load} compact />
{!loading && !loadError && items.length === 0 && <Alert severity="info" variant="outlined">{t("attachmentsEmpty")}</Alert>}
{items.length > 0 && <TableContainer role="region" tabIndex={0} aria-label={t("attachmentsTitle", { count })} sx={{ maxWidth: "100%", borderRadius: 1, border: 1, borderColor: "divider", overflowX: "auto" }}>
<Table size="small" aria-label={t("workspaceCurrentDocuments")} sx={{ minWidth: 900 }}>
<TableHead>
<TableRow>
<TableCell>{t("attachmentsName")}</TableCell>
<TableCell sx={{ width: 140 }}>{t("attachmentsKind")}</TableCell>
<TableCell sx={{ width: 120 }}>{t("attachmentsType")}</TableCell>
<TableCell sx={{ width: 90 }}>{t("attachmentsSize")}</TableCell>
<TableCell>{t("attachmentsUploaded")}</TableCell>
<TableCell sx={{ width: 160 }}>{t("attachmentsPurpose")}</TableCell>
<TableCell sx={{ width: 110 }}>{t("attachmentsAiUse")}</TableCell>
<TableCell sx={{ width: 170 }}>{t("attachmentsActions")}</TableCell>
@@ -288,14 +297,14 @@ export default function Attachments({ jobId }: { jobId: number }) {
return (
<TableRow key={a.id} hover>
<TableCell>
<Button size="small" variant="text" onClick={() => void download(a)} sx={{ fontWeight: 800, textTransform: "none" }}>
<Button size="small" variant="text" onClick={() => void download(a)} sx={{ fontWeight: 700, textTransform: "none", textAlign: "left", overflowWrap: "anywhere" }}>
{a.fileName}
</Button>
</TableCell>
<TableCell>{kind}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize) : ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
<TableCell>
<FormControl size="small" fullWidth>
<InputLabel id={`${labelId}-${a.id}`}>{t("attachmentsPurpose")}</InputLabel>
@@ -337,18 +346,9 @@ export default function Attachments({ jobId }: { jobId: number }) {
);
})}
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={8}>
<Alert severity="info" variant="outlined">
{t("attachmentsEmpty")}
</Alert>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</TableContainer>
</TableContainer>}
<Dialog open={previewOpen} onClose={() => setPreviewOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{preview?.name ? t("attachmentsPreviewTitle", { name: preview.name }) : t("attachmentsPreview")}</DialogTitle>
@@ -43,6 +43,7 @@ import {
} from "../types";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import ViewStateNotice from "./ViewStateNotice";
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
const lines = raw.replace(/\r\n/g, "\n").split("\n");
@@ -127,6 +128,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
return label;
};
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(true);
const [messagesError, setMessagesError] = useState<string | null>(null);
const [from, setFrom] = useState<"Me" | "Company">("Me");
const [text, setText] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -154,9 +157,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const autoRefreshKeyRef = useRef<string | null>(null);
const load = useCallback(async () => {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
}, [jobId]);
setMessagesLoading(true);
try {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
setMessagesError(null);
} catch (error) {
setMessagesError(getApiErrorMessage(error, t("correspondenceLoadFailed")));
throw error; // Existing mutation callers still handle their own operation failure.
} finally {
setMessagesLoading(false);
}
}, [jobId, t]);
const loadGmailStatus = useCallback(async () => {
try {
@@ -242,7 +254,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]);
useEffect(() => {
void load();
void load().catch(() => undefined);
}, [load]);
useEffect(() => {
@@ -460,8 +472,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
return (
<Box>
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
{messages.length === 0 ? (
<ViewStateNotice title={t("correspondenceLoadFailed")} loading={messagesLoading} error={messagesError ? { kind: "error", message: messagesError, retryable: true } : null} onRetry={() => load().catch(() => undefined)} compact />
<Paper variant="outlined" ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", bgcolor: "background.paper", borderRadius: 1 }}>
{messages.length === 0 && !messagesLoading && !messagesError ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
@@ -470,7 +483,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main;
return (
<Box key={m.id} sx={{ display: "flex", justifyContent: isMe ? "flex-end" : "flex-start" }}>
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
<Box sx={{ maxWidth: { xs: "100%", sm: "85%" }, minWidth: 0, overflowWrap: "anywhere", borderRadius: 1, p: 1.25, border: "1px solid", borderColor: "divider", borderLeft: `3px solid ${accent}`, bgcolor: "background.default", color: "text.primary", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
@@ -498,7 +511,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
)}
</Paper>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 1, border: 1, borderColor: "divider", bgcolor: "background.default", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Typography variant="overline">{t("correspondenceLinkedContinuity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
{t("correspondenceLinkedContinuityHelp")}
@@ -548,7 +561,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={t("correspondenceCharacters", { count: text.length })} />
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: { xs: "1 1 100%", sm: "1 1 320px" }, minWidth: 0 }} helperText={t("correspondenceCharacters", { count: text.length })} />
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box>
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Button, Checkbox, Chip, Divider, IconButton, LinearProgress, MenuItem, Paper,
Skeleton, Stack, TextField, Tooltip, Typography,
Alert, Box, Button, Checkbox, Chip, IconButton, LinearProgress, MenuItem, Paper,
Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
@@ -16,6 +16,8 @@ import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
// Phase 5.5 — Interview preparation and follow-up.
//
@@ -31,18 +33,10 @@ function Shell({ title, subtitle, loading, error, children }: {
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
</PageSection>
);
}
@@ -367,6 +361,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
fullWidth
size="small"
placeholder={t("interviewPrepAnswerPlaceholder")}
inputProps={{ "aria-label": t("interviewPrepAnswerFor", { title: item.title }) }}
value={content}
disabled={busy || saving}
onChange={(e) => setDraft(e.target.value)}
+30
View File
@@ -2408,6 +2408,21 @@ export const translations = {
workspaceBack: "Back to applications",
workspaceOpenFull: "Open full-page workspace",
workspaceSections: "Workspace sections",
workspaceMatch: "Match",
workspaceApplication: "Application",
workspaceTimeline: "Timeline",
workspaceTasks: "Notes & tasks",
workspaceApplicationSections: "Application materials",
workspaceTimelineSections: "Timeline sections",
workspaceAnswers: "Answers & message draft",
workspaceSubmission: "Submission",
workspaceCurrentDocuments: "Current documents",
workspaceCurrentDocumentsHelp: "Manage the files attached to this application. Submitted snapshots below keep their original contents independently of these current files.",
workspaceFrozenHelp: "This is an immutable submission snapshot. Its captured CV version, letter and files are not replaced when current documents change. Freezing a package does not send an application.",
workspaceNoNotes: "No personal notes yet. Application answers have their own draft in Application.",
attachmentsLoadFailed: "Could not load this application's attachments.",
correspondenceLoadFailed: "Could not load this application's correspondence.",
interviewPrepAnswerFor: "Preparation answer: {title}",
workspaceOverview: "Overview",
workspaceAnalysis: "Analysis",
workspaceCv: "CV",
@@ -4924,6 +4939,21 @@ export const translations = {
workspaceBack: "Tilbake til søknader",
workspaceOpenFull: "Åpne arbeidsområdet på helside",
workspaceSections: "Deler av arbeidsområdet",
workspaceMatch: "Samsvar",
workspaceApplication: "Søknad",
workspaceTimeline: "Tidslinje",
workspaceTasks: "Notater og oppgaver",
workspaceApplicationSections: "Søknadsmateriale",
workspaceTimelineSections: "Deler av tidslinjen",
workspaceAnswers: "Svar og meldingsutkast",
workspaceSubmission: "Innsending",
workspaceCurrentDocuments: "Gjeldende dokumenter",
workspaceCurrentDocumentsHelp: "Administrer filene knyttet til denne søknaden. Innsendte øyeblikksbilder nedenfor beholder det opprinnelige innholdet uavhengig av disse filene.",
workspaceFrozenHelp: "Dette er et uforanderlig øyeblikksbilde av innsendingen. Den lagrede CV-versjonen, brevet og filene erstattes ikke når gjeldende dokumenter endres. Å fryse en pakke sender ikke en søknad.",
workspaceNoNotes: "Ingen personlige notater ennå. Søknadssvar har et eget utkast under Søknad.",
attachmentsLoadFailed: "Kunne ikke laste vedleggene til denne søknaden.",
correspondenceLoadFailed: "Kunne ikke laste korrespondansen til denne søknaden.",
interviewPrepAnswerFor: "Forberedelsessvar: {title}",
workspaceOverview: "Oversikt",
workspaceAnalysis: "Analyse",
workspaceCv: "CV",
@@ -27,7 +27,7 @@ import {
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
} from "../components/ApplicationIntelligence";
import {
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection,
} from "../components/ApplicationAssets";
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import EditJobDialog from "../components/EditJobDialog";
@@ -40,7 +40,7 @@ import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
checklistSystemTranslationToken, workspaceSection,
checklistSystemTranslationToken, workspaceSection, workspaceSubsection,
} from "../applicationWorkspace";
// Phase 5 Milestone 1 — the dedicated Application Workspace.
@@ -54,7 +54,7 @@ export default function ApplicationWorkspacePage() {
type ApplicationWorkspaceProps = {
jobIdOverride?: number;
sectionOverride?: WorkspaceSectionKey;
sectionOverride?: WorkspaceSectionKey | "analysis" | "cv" | "cover-letter";
onSectionChange?: (section: WorkspaceSectionKey) => void;
onClose?: () => void;
fullPageHref?: string;
@@ -74,8 +74,13 @@ export function ApplicationWorkspace({
const { t } = useI18n();
const { confirm } = useConfirm();
const [params, setParams] = useSearchParams();
const section = sectionOverride ?? workspaceSection(params.get("section"));
const section = workspaceSection(sectionOverride ?? params.get("section"));
const [overlaySubsection, setOverlaySubsection] = useState<{ section: WorkspaceSectionKey; value: string } | null>(null);
const subsection = onSectionChange && overlaySubsection?.section === section
? overlaySubsection.value : workspaceSubsection(sectionOverride ?? params.get("section"), params.get("sub"));
const sectionId = useId();
const secondaryId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<ViewResourceError | null>(null);
@@ -140,15 +145,25 @@ export function ApplicationWorkspace({
load();
}, [load]);
const go = (next: string) => {
useEffect(() => {
// Direct entry gets a named target; keyboard tab activation keeps tab focus.
if (!loading && section !== "overview" && document.activeElement === document.body) panelRef.current?.focus({ preventScroll: true });
}, [loading, section, subsection]);
const go = (next: string, sub?: string) => {
if (next === "career-profile") {
navigate("/career?section=profile");
return;
}
if (onSectionChange) onSectionChange(workspaceSection(next));
if (onSectionChange) {
setOverlaySubsection({ section: workspaceSection(next), value: workspaceSubsection(next, sub ?? null) });
onSectionChange(workspaceSection(next));
}
else setParams((current) => {
const updated = new URLSearchParams(current);
updated.set("section", next);
if (sub) updated.set("sub", sub);
else updated.delete("sub");
return updated;
}, { state: location.state });
};
@@ -161,6 +176,15 @@ export function ApplicationWorkspace({
{ replace: true, state: focusJobId ? { focusJobId } : undefined },
);
});
const subsections = section === "application" ? [
{ key: "cv", label: t("workspaceCv") },
{ key: "letter", label: t("workspaceCoverLetter") },
{ key: "answers", label: t("workspaceAnswers") },
{ key: "submission", label: t("workspaceSubmission") },
] : [
{ key: "activity", label: t("workspaceActivityHistory") },
{ key: "correspondence", label: t("workspaceCommunication") },
];
if (error) {
return (
@@ -213,10 +237,10 @@ export function ApplicationWorkspace({
</Tabs>
</Paper>
<Box role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
<Box ref={panelRef} role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
{section === "analysis" && jobId > 0 && (
{section === "match" && jobId > 0 && (
<Stack spacing={2}>
<ApplicationAnalysis jobId={jobId} />
<ApplicationMatch jobId={jobId} />
@@ -224,19 +248,39 @@ export function ApplicationWorkspace({
</Stack>
)}
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
{section === "cover-letter" && jobId > 0 && (
<>
<ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />
<ApplicationPackageDraftsSection
{(section === "application" || section === "timeline") && <>
<Tabs value={subsection} onChange={(_, sub: string) => go(section, sub)} variant="scrollable" scrollButtons="auto" aria-label={t(section === "application" ? "workspaceApplicationSections" : "workspaceTimelineSections")}>
{subsections.map((sub) => <Tab key={sub.key} value={sub.key} id={`${secondaryId}-${sub.key}`} aria-controls={`${secondaryId}-panel`} label={sub.label} />)}
</Tabs>
<Box role="tabpanel" id={`${secondaryId}-panel`} aria-labelledby={`${secondaryId}-${subsection}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} includePackages={false} />}
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
{section === "application" && subsection === "answers" && jobId > 0 && <ApplicationPackageDraftsSection
jobId={jobId}
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
onSaved={load}
onDirtyChange={setPackageDraftsDirty}
/>
</>
)}
/>}
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection jobId={jobId} />}
{section === "timeline" && subsection === "activity" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
{section === "timeline" && subsection === "correspondence" && jobId > 0 && <PageSection surface title={t("workspaceCommunication")} level="h3"><Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} /></PageSection>}
</Box>
</>}
{section === "documents" && jobId > 0 && <>
<PageSection surface title={t("workspaceCurrentDocuments")} description={t("workspaceCurrentDocumentsHelp")} actions={<>
<Button onClick={() => go("application", "cv")}>{t("workspaceCv")}</Button>
<Button onClick={() => go("application", "letter")}>{t("workspaceCoverLetter")}</Button>
</>}><Attachments jobId={jobId} /></PageSection>
<SubmittedPackagesSection jobId={jobId} allowCapture={false} />
</>}
{section === "tasks" && jobId > 0 && <>
<PageSection surface title={t("workspaceNotes")} actions={<Button startIcon={<EditOutlinedIcon />} onClick={() => setEditOpen(true)}>{t("workspaceEdit")}</Button>}>
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{overview?.notes || t("workspaceNoNotes")}</Typography>
</PageSection>
<PageSection title={t("workspaceChecklist")}><ApplicationChecklist jobId={jobId} onChanged={load} /></PageSection>
<PageSection surface title={t("calendarTitle")}><ApplicationCalendarActions jobId={jobId} followUpAt={overview?.followUpAt ?? null} deadline={overview?.deadline ?? null} /></PageSection>
</>}
</Box>
<EditJobDialog
open={editOpen}
@@ -391,17 +435,17 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
)}
</PageSection>
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
<OverviewDetails overview={overview} onEdit={onEdit} />
</Stack>
);
}
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
function OverviewDetails({ overview, onEdit }: { overview: WorkspaceOverview; onEdit: () => void }) {
const { t } = useI18n();
const [params] = useSearchParams();
const prefix = useId();
const triggers = useRef<Record<string, HTMLDivElement | null>>({});
const targets: Record<string, string> = { "job-details": "details", checklist: "tasks", documents: "documents", portfolio: "documents", timeline: "timeline", communication: "communication" };
const targets: Record<string, string> = { "job-details": "details" };
const key = params.get("section") ?? "";
const target = Object.prototype.hasOwnProperty.call(targets, key) ? targets[key] : undefined;
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
@@ -412,11 +456,6 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
}, [target]);
const panels = [
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
{ id: "calendar", title: t("calendarTitle"), content: <ApplicationCalendarActions jobId={jobId} followUpAt={overview.followUpAt} deadline={overview.deadline} /> },
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
];
return (
<Box>
@@ -435,9 +474,11 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string {
const keys: Record<WorkspaceSectionKey, any> = {
overview: "workspaceOverview",
analysis: "workspaceAnalysis",
cv: "workspaceCv",
"cover-letter": "workspaceCoverLetter",
match: "workspaceMatch",
application: "workspaceApplication",
documents: "workspaceDocuments",
timeline: "workspaceTimeline",
tasks: "workspaceTasks",
interview: "workspaceInterviewPrep",
};
return t(keys[section]);