feat(ui): redesign job workspace sections
This commit is contained in:
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user