fix(workspace): clear native package and navigation readiness gates
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { expect, test as base, type APIRequestContext } from "@playwright/test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { buildJobWorkspacePath } from "../src/jobWorkspaceRoute";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
const apiUrl = "http://localhost:5302/api";
|
||||
const password = "E2ePassword123!";
|
||||
@@ -67,7 +70,8 @@ test("[PRES-C01 PRES-C06 PRES-C07 PRES-C10] mobile CV override persists without
|
||||
expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(profileBefore.profile);
|
||||
});
|
||||
|
||||
test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change", async ({ page, headers }) => {
|
||||
test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change", async ({ page, headers, playwright }, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Preservation AS" } });
|
||||
expect(companyResponse.status()).toBe(201);
|
||||
const company = await companyResponse.json();
|
||||
@@ -77,9 +81,14 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
|
||||
const variantResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Submitted CV", jobApplicationId: job.id } });
|
||||
expect(variantResponse.status()).toBe(200);
|
||||
const variant = await variantResponse.json();
|
||||
const siblingResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Sibling CV" } });
|
||||
expect(siblingResponse.status()).toBe(200);
|
||||
const sibling = await siblingResponse.json();
|
||||
const jobUrl = `${apiUrl}/jobapplications/${job.id}`;
|
||||
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: variant.id } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: "Original reviewed letter", source: "manual" } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: "Original answer — æøå", recruiterMessageDraft: "Original recruiter message" } })).status()).toBe(204);
|
||||
expect((await page.request.get(`${jobUrl}/tailoring`)).status()).toBe(200);
|
||||
const bytes = Buffer.from("Original attachment — æøå\n", "utf8");
|
||||
const upload = await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(job.id), files: { name: "evidence.txt", mimeType: "text/plain", buffer: bytes } } });
|
||||
expect(upload.ok()).toBe(true);
|
||||
@@ -89,12 +98,8 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
|
||||
const captured = page.waitForResponse(response => response.url() === `${jobUrl}/submitted-packages` && response.request().method() === 'POST');
|
||||
await page.getByRole("button", { name: "Freeze current package", exact: true }).click();
|
||||
const captureResponse = await captured;
|
||||
// P2-D01: native SQLite rejects the service's DateTimeOffset ORDER BY. Only
|
||||
// this known response is expected; setup failures and later archive failures
|
||||
// after a successful capture must remain ordinary failing assertions.
|
||||
if (captureResponse.status() === 500) {
|
||||
test.fail(true, 'P2-D01: SQLite submitted-package capture; phase-2/BASELINE_DEFECTS.md');
|
||||
}
|
||||
// P2-D01 is now an ordinary regression: native capture AND preserved readback
|
||||
// must succeed; a capture 500 can no longer be classified as expected.
|
||||
expect(captureResponse.status()).toBe(200);
|
||||
await expect(page.getByText("Submission package v1", { exact: true })).toBeVisible();
|
||||
const [snapshot] = await (await page.request.get(`${jobUrl}/submitted-packages`)).json();
|
||||
@@ -102,19 +107,105 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
|
||||
expect(before.coverLetterText).toBe("Original reviewed letter");
|
||||
expect(before.renderedCvHtml).toContain("Canonical engineer");
|
||||
expect(snapshot.cvVariantId).toBe(variant.id);
|
||||
expect(snapshot.cvVariantVersion).toBe(variant.version);
|
||||
expect(before.applicationMaterialJson).toContain("Original answer");
|
||||
expect(snapshot.attachments).toHaveLength(1);
|
||||
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: "Later letter", source: "manual" } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${apiUrl}/cv/variants/${variant.id}`, { headers, data: { name: "Later CV", settings: { ...variant.settings, headline: "Later headline" } } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { ...canonical, contact: { ...canonical.contact, headline: "Later canonical headline" } } } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: "Later answer", recruiterMessageDraft: "Later recruiter message" } })).status()).toBe(204);
|
||||
expect((await page.request.delete(`${apiUrl}/attachments/${attachment.id}`, { headers })).status()).toBe(204);
|
||||
expect(await (await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}`)).json()).toEqual(before);
|
||||
const archived = await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}/attachments/${snapshot.attachments[0].id}`);
|
||||
expect(archived.status()).toBe(200);
|
||||
expect(await archived.body()).toEqual(bytes);
|
||||
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: null } })).status()).toBe(200);
|
||||
expect((await page.request.get(`${apiUrl}/cv/variants/${variant.id}`)).status()).toBe(200);
|
||||
expect(await (await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)).json()).toEqual(sibling);
|
||||
const restored = await page.request.post(`${jobUrl}/cover-letter/versions/1/restore`, { headers });
|
||||
expect(restored.status()).toBe(200);
|
||||
expect((await restored.json()).currentVersion).toBe(3);
|
||||
expect((await (await page.request.get(`${jobUrl}/cover-letter`)).json()).text).toBe("Original reviewed letter");
|
||||
expect(await (await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}`)).json()).toEqual(before);
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "View preserved package", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").getByText("Original reviewed letter", { exact: true })).toBeVisible();
|
||||
await testInfo.attach("native-frozen-package", { body: await page.screenshot({ fullPage: true }), contentType: "image/png" });
|
||||
await testInfo.attach("native-preservation-invariants", { body: JSON.stringify({ packageId: snapshot.id, version: snapshot.version, cvVariantId: snapshot.cvVariantId, cvVariantVersion: snapshot.cvVariantVersion, attachment: { name: snapshot.attachments[0].fileName, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length }, immutableReadback: true, canonicalAndVariantAndLetterAndAnswerMutated: true, liveAttachmentDeleted: true, siblingUnchanged: true, detachPreservedVariant: true }), contentType: "application/json" });
|
||||
const admin = await playwright.request.newContext();
|
||||
const other = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, "e2e@example.test");
|
||||
const email = `package-other-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const otherHeaders = await login(other, email);
|
||||
for (const path of [`${jobUrl}/submitted-packages`, `${jobUrl}/submitted-packages/${snapshot.id}`, `${jobUrl}/submitted-packages/${snapshot.id}/attachments/${snapshot.attachments[0].id}`]) expect((await other.get(path)).status()).toBe(404);
|
||||
expect((await other.put(`${jobUrl}/cv`, { headers: otherHeaders, data: { variantId: variant.id } })).status()).toBe(404);
|
||||
expect((await other.post(`${jobUrl}/submitted-packages`, { headers: otherHeaders })).status()).toBe(404);
|
||||
const otherCompany = await other.post(`${apiUrl}/companies`, { headers: otherHeaders, data: { name: "Other-owner AS" } });
|
||||
expect(otherCompany.status()).toBe(201);
|
||||
const otherJob = await other.post(`${apiUrl}/jobapplications`, { headers: otherHeaders, data: { companyId: (await otherCompany.json()).id, jobTitle: "Other owner", status: "Saved" } });
|
||||
expect(otherJob.status()).toBe(201);
|
||||
expect((await other.put(`${apiUrl}/jobapplications/${(await otherJob.json()).id}/cv`, { headers: otherHeaders, data: { variantId: variant.id } })).status()).toBe(404);
|
||||
} finally { await other.dispose(); await admin.dispose(); }
|
||||
});
|
||||
|
||||
for (const language of ["en", "nb"] as const) {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`[PRES-W01 PRES-W04 PRES-W08 PRES-E08 PRES-E09] native workspace aliases, keyboard sections and history retain context (${language}, ${theme})`, async ({ page, headers }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(({ locale, mode }) => { localStorage.setItem("uiLanguage", locale); localStorage.setItem("jobtracker.themeMode", mode); }, { locale: language, mode: theme });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204);
|
||||
const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Navigasjon AS" } });
|
||||
expect(companyResponse.status()).toBe(201);
|
||||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await companyResponse.json()).id, jobTitle: "Readiness workspace", status: "Saved", description: "C# engineer" } });
|
||||
expect(created.ok()).toBe(true);
|
||||
const job = await created.json();
|
||||
const expectedSections = ["overview", "communication", "documents", "cv", "communication", "match", "analysis", "interview", "checklist", "timeline"];
|
||||
for (let tab = 0; tab < expectedSections.length; tab++) {
|
||||
const path = buildJobWorkspacePath(job.id, { tab, followMode: "waiting-update" });
|
||||
await page.goto(path);
|
||||
await expect(page.getByRole("heading", { name: "Readiness workspace", exact: true })).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get("followMode")).toBe("waiting-update");
|
||||
const section = expectedSections[tab];
|
||||
if (["communication", "documents", "checklist", "timeline"].includes(section)) {
|
||||
const names = language === "en" ? { communication: "Communication", documents: "Documents", checklist: "Next actions and checklist", timeline: "Activity history" } : { communication: "Kommunikasjon", documents: "Dokumenter", checklist: "Neste handlinger og sjekkliste", timeline: "Aktivitetshistorikk" };
|
||||
await expect(page.getByRole("button", { name: names[section as keyof typeof names], exact: true })).toHaveAttribute("aria-expanded", "true");
|
||||
}
|
||||
await expect(page.getByRole("tabpanel")).toBeVisible();
|
||||
}
|
||||
await page.goto(`/jobs/${job.id}?section=checklist&followMode=waiting-update`);
|
||||
const move = page.getByRole("button", { name: language === "en" ? /^Move up:/ : /^Flytt opp:/ }).first();
|
||||
await move.focus();
|
||||
await expect(move).toBeFocused();
|
||||
await expect.poll(() => move.evaluate(node => getComputedStyle(node.closest(".checklist-actions")!).opacity)).toBe("1");
|
||||
await testInfo.attach(`workspace-native-desktop-${language}-${theme}`, { body: await page.screenshot({ fullPage: true }), contentType: "image/png" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/jobs/${job.id}?section=documents&followMode=waiting-update`);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "Readiness workspace", exact: true })).toBeVisible();
|
||||
const navigationScan = await new AxeBuilder({ page }).include('[role="tablist"]').analyze();
|
||||
await testInfo.attach("navigation-axe", { body: JSON.stringify({ violations: navigationScan.violations, incomplete: navigationScan.incomplete }), contentType: "application/json" });
|
||||
expect(navigationScan.violations).toEqual([]);
|
||||
await testInfo.attach(`workspace-native-disclosure-${language}-${theme}`, { body: await page.screenshot({ fullPage: true }), contentType: "image/png" });
|
||||
const overviewTab = page.getByRole("tab", { name: language === "en" ? "Overview" : "Oversikt", exact: true });
|
||||
await overviewTab.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const analysisTab = page.getByRole("tab", { name: language === "en" ? "Analysis" : "Analyse", exact: true });
|
||||
await expect(analysisTab).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(analysisTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(new URL(page.url()).searchParams.get("followMode")).toBe("waiting-update");
|
||||
await page.goBack();
|
||||
await expect(overviewTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(new URL(page.url()).searchParams.get("section")).toBe("documents");
|
||||
await page.goForward();
|
||||
await expect(analysisTab).toHaveAttribute("aria-selected", "true");
|
||||
await testInfo.attach(`workspace-native-navigation-${language}-${theme}`, { body: await page.screenshot({ fullPage: true }), contentType: "image/png" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("[PRES-S03 PRES-W11] responsive settings navigation keeps rule edits and persists notification preferences", async ({ page, headers }) => {
|
||||
expect((await page.request.get(`${apiUrl}/auth/me`, { headers })).status()).toBe(200);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
@@ -268,6 +268,8 @@ test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without
|
||||
const onDirtyChange = jest.fn();
|
||||
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
|
||||
await screen.findByText(/application's full job advert and analysis/i);
|
||||
expect(screen.getByText(/Using Backend CV and this application's full job advert and analysis/i)).toBeInTheDocument();
|
||||
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");
|
||||
|
||||
@@ -319,3 +319,41 @@ test("uses recorded dates in the summary and keeps absent dates unknown", async
|
||||
expect(within(summary).getAllByText("—")).toHaveLength(2);
|
||||
expect(screen.queryByRole("link", { name: "Open original advert" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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" }));
|
||||
});
|
||||
|
||||
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" });
|
||||
fireEvent.click(trigger);
|
||||
const target = trigger.getAttribute("aria-controls")!;
|
||||
expect(document.querySelectorAll(`[id='${target}']`)).toHaveLength(1);
|
||||
expect(document.getElementById(target)).toHaveAttribute("role", "region");
|
||||
expect(document.getElementById(target)).toHaveAttribute("aria-labelledby", trigger.id);
|
||||
});
|
||||
|
||||
test("legacy document links reveal their existing content and focus the disclosure", 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");
|
||||
});
|
||||
|
||||
test("the missing-profile next task opens the existing canonical profile entry", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { ...overview, nextStep: { key: "complete-career-profile", label: "Complete your career profile", reason: "Canonical data", section: "checklist" } } } as any);
|
||||
renderTable("/jobs/42");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Complete your career profile" }));
|
||||
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/career?section=profile"));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7,4 +7,9 @@ test("workspace navigation exposes five purposes and preserves legacy links", ()
|
||||
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("unknown-section")).toBe("overview");
|
||||
expect(workspaceSection("constructor")).toBe("overview");
|
||||
expect(workspaceSection("__proto__")).toBe("overview");
|
||||
});
|
||||
|
||||
@@ -101,11 +101,13 @@ const LEGACY_WORKSPACE_SECTIONS: Record<string, WorkspaceSectionKey> = {
|
||||
documents: "overview",
|
||||
timeline: "overview",
|
||||
communication: "overview",
|
||||
portfolio: "overview",
|
||||
notes: "cover-letter",
|
||||
};
|
||||
|
||||
/** Preserve old shared/bookmarked workspace links while keeping one clear primary navigation. */
|
||||
export const workspaceSection = (value: string | null): WorkspaceSectionKey => {
|
||||
if (value && value in LEGACY_WORKSPACE_SECTIONS) return LEGACY_WORKSPACE_SECTIONS[value];
|
||||
if (value && Object.prototype.hasOwnProperty.call(LEGACY_WORKSPACE_SECTIONS, value)) return LEGACY_WORKSPACE_SECTIONS[value];
|
||||
return WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
|
||||
};
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
||||
<Alert severity="info">{t("coverAiSelectCv")}</Alert>
|
||||
) : (
|
||||
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
|
||||
{t("coverAiUsing")} <strong>{cv?.attachedVariantName}</strong>
|
||||
{t("coverAiUsing", { name: cv?.attachedVariantName ?? "" })}
|
||||
</Alert>
|
||||
)}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
spacing={1}
|
||||
sx={{ py: 0.75, borderRadius: 2, "&:hover .checklist-actions": { opacity: 1 } }}
|
||||
sx={{ py: 0.75, borderRadius: 2, "&:hover .checklist-actions, &:focus-within .checklist-actions": { opacity: 1 } }}
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BlockerFunction, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams, useSearchParams,
|
||||
} from "react-router-dom";
|
||||
@@ -75,6 +75,7 @@ export function ApplicationWorkspace({
|
||||
const { confirm } = useConfirm();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const section = sectionOverride ?? workspaceSection(params.get("section"));
|
||||
const sectionId = useId();
|
||||
|
||||
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
|
||||
const [error, setError] = useState<ViewResourceError | null>(null);
|
||||
@@ -139,9 +140,17 @@ export function ApplicationWorkspace({
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const go = (next: WorkspaceSectionKey) => {
|
||||
if (onSectionChange) onSectionChange(next);
|
||||
else setParams({ section: next }, { replace: true, state: location.state });
|
||||
const go = (next: string) => {
|
||||
if (next === "career-profile") {
|
||||
navigate("/career?section=profile");
|
||||
return;
|
||||
}
|
||||
if (onSectionChange) onSectionChange(workspaceSection(next));
|
||||
else setParams((current) => {
|
||||
const updated = new URLSearchParams(current);
|
||||
updated.set("section", next);
|
||||
return updated;
|
||||
}, { state: location.state });
|
||||
};
|
||||
const close = onClose ?? (() => {
|
||||
const state = location.state as { from?: unknown; focusJobId?: unknown } | null;
|
||||
@@ -200,11 +209,11 @@ export function ApplicationWorkspace({
|
||||
aria-label={t("workspaceSections")}
|
||||
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
|
||||
>
|
||||
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
||||
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} id={`${sectionId}-${s.key}`} aria-controls={`${sectionId}-panel`} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
<Box 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 && (
|
||||
@@ -296,7 +305,7 @@ function ApplicationProgress({ status }: { status: string }) {
|
||||
|
||||
function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
||||
overview: WorkspaceOverview | null;
|
||||
onGo: (s: WorkspaceSectionKey) => void;
|
||||
onGo: (s: string) => void;
|
||||
onReload: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
@@ -304,9 +313,9 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
||||
const stats = useMemo(() => overview ? [
|
||||
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: t("workspaceCv"), value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? t("workspaceTailoredText") : t("workspaceNotPrepared")), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
|
||||
{ icon: <MailOutlineIcon fontSize="small" />, label: t("jobDetailsCoverLetter"), value: overview.hasCoverLetter ? t("workspaceReady") : t("workspaceNotWritten"), ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
||||
{ icon: <FolderOutlinedIcon fontSize="small" />, label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "overview" as const },
|
||||
{ icon: <FolderOutlinedIcon fontSize="small" />, label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "documents" as const },
|
||||
{ icon: <AutoFixHighIcon fontSize="small" />, label: t("workspaceAiSuggestions"), value: overview.aiInteractionCount ? t("workspaceSavedCount", { count: overview.aiInteractionCount }) : t("workspaceNoneYet"), ok: overview.aiInteractionCount > 0, go: "analysis" as const },
|
||||
{ icon: <ChecklistIcon fontSize="small" />, label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const },
|
||||
{ icon: <ChecklistIcon fontSize="small" />, label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const },
|
||||
] : [], [overview, t]);
|
||||
|
||||
if (!overview) {
|
||||
@@ -341,7 +350,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
||||
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography>
|
||||
<Button variant="contained" endIcon={<ArrowForwardIcon />}
|
||||
onClick={() => overview.nextStep?.section && onGo(overview.nextStep.section as WorkspaceSectionKey)}>
|
||||
onClick={() => onGo(overview.nextStep?.key === "complete-career-profile" ? "career-profile" : overview.nextStep?.section ?? "checklist")}>
|
||||
{nextStepLabel}
|
||||
</Button>
|
||||
</PageSection>
|
||||
@@ -389,6 +398,18 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
||||
|
||||
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; 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 key = params.get("section") ?? "";
|
||||
const target = Object.prototype.hasOwnProperty.call(targets, key) ? targets[key] : undefined;
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
setExpanded((current) => ({ ...current, [target]: true }));
|
||||
triggers.current[target]?.focus();
|
||||
}, [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} /> },
|
||||
@@ -400,11 +421,11 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
|
||||
return (
|
||||
<Box>
|
||||
{panels.map((panel) => (
|
||||
<Accordion key={panel.id} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${panel.id}-content`} id={`${panel.id}-header`}>
|
||||
<Accordion key={panel.id} expanded={!!expanded[panel.id]} onChange={(_, open) => setExpanded((current) => ({ ...current, [panel.id]: open }))} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
|
||||
<AccordionSummary ref={(node: HTMLDivElement | null) => { triggers.current[panel.id] = node; }} expandIcon={<ExpandMoreIcon />} aria-controls={`${prefix}-${panel.id}-content`} id={`${prefix}-${panel.id}-header`}>
|
||||
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails id={`${panel.id}-content`} sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
|
||||
<AccordionDetails sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user