feat(workspace): refine career workflows

Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
This commit is contained in:
cesnimda
2026-08-29 16:44:44 +02:00
parent 4d4af47651
commit 9edcbfc5de
29 changed files with 827 additions and 236 deletions
+21 -2
View File
@@ -128,10 +128,14 @@ test("a Free account keeps manual work available while AI actions stay honestly
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
await page.setViewportSize({ width: 375, height: 900 });
await page.goto("/career");
await page.getByRole("combobox", { name: "Career workspace section" }).click();
await page.getByRole("option", { name: "Import review" }).click();
await expect(page.getByText(/Build your Career Profile faster with Pro/)).toBeVisible();
await expect(page.getByLabel("Professional headline")).toBeEnabled();
await page.getByRole("button", { name: "Dismiss Build your Career Profile faster with Pro." }).click();
await expect(page.getByText(/Build your Career Profile faster with Pro/)).toHaveCount(0);
await page.getByRole("combobox", { name: "Career workspace section" }).click();
await page.getByRole("option", { name: "Career profile" }).click();
await expect(page.getByLabel("Professional headline")).toBeEnabled();
await page.goto("/settings");
await expect(page.getByText(/Free plan includes core job tracking without AI/i)).toBeVisible();
@@ -140,7 +144,8 @@ test("a Free account keeps manual work available while AI actions stay honestly
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
await page.getByRole("tab", { name: "Backup" }).click();
await page.getByLabel("Settings section").click();
await page.getByRole("option", { name: "Backup" }).click();
await expect(page.getByText(/requires a sign-in from the last 15 minutes/i)).toBeVisible();
const exportResponsePromise = page.waitForResponse((response) => response.url().endsWith("/api/export/account") && response.request().method() === "POST");
await page.getByRole("button", { name: "Download readable account export" }).click();
@@ -302,6 +307,20 @@ test("Career Workspace loads from the authenticated application shell", async ({
await expect(page.getByRole("heading", { name: "Career Workspace" })).toBeVisible();
await expect(page.getByRole("link", { name: "Open CV Builder" })).toHaveAttribute("href", "/career/builder");
await page.setViewportSize({ width: 375, height: 900 });
const careerSection = page.getByRole("combobox", { name: "Career workspace section" });
await expect(careerSection).toBeVisible();
await careerSection.click();
await page.getByRole("option", { name: "Career profile" }).click();
await expect(page).toHaveURL(/\/career\?section=profile$/);
await expect(page.getByRole("heading", { name: "Career profile" })).toBeVisible();
await careerSection.click();
await page.getByRole("option", { name: "Import review" }).click();
await expect(page).toHaveURL(/\/career\?section=import$/);
await expect(page.getByRole("heading", { name: "Master CV" })).toBeVisible();
await careerSection.click();
await page.getByRole("option", { name: "Overview" }).click();
for (const width of [375, 768, 1440]) {
await page.setViewportSize({ width, height: 900 });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+3 -3
View File
@@ -29,12 +29,12 @@
"web-vitals": "^2.1.4"
},
"scripts": {
"dev": "next dev",
"start": "next dev",
"dev": "next dev --webpack",
"start": "next dev --webpack",
"serve:export": "node ./scripts/serve-export.mjs",
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
"test": "jest",
"test:e2e": "dotnet build ../JobTrackerApi/JobTrackerApi.csproj --configuration Release && playwright test"
"test:e2e": "node ./scripts/run-e2e.mjs"
},
"browserslist": {
"production": [
+8 -3
View File
@@ -7,6 +7,11 @@ const appUrl = "http://localhost:3300";
const dataRoot = path.join(os.tmpdir(), `jobtracker-e2e-${process.pid}-${Date.now().toString(36)}`);
const reuseServers = process.env.PLAYWRIGHT_REUSE_SERVERS === "true";
const useStaticExport = process.env.PLAYWRIGHT_STATIC_EXPORT === "true";
const quote = (value: string) => `"${value.replaceAll('"', '\\"')}"`;
const nodeCommand = quote(process.execPath);
const dotnetCommand = quote(process.env.DOTNET_HOST_PATH || "dotnet");
const nextCommand = quote(path.resolve("node_modules/next/dist/bin/next"));
const exportServerCommand = quote(path.resolve("scripts/serve-export.mjs"));
export default defineConfig({
testDir: "./e2e",
@@ -22,7 +27,7 @@ export default defineConfig({
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: [
{
command: "dotnet run --no-build --configuration Release --project ../JobTrackerApi/JobTrackerApi.csproj --urls http://localhost:5302",
command: `${dotnetCommand} run --no-build --configuration Release --project ../JobTrackerApi/JobTrackerApi.csproj --urls http://localhost:5302`,
url: `${apiUrl}/health`,
timeout: 120_000,
reuseExistingServer: reuseServers,
@@ -43,8 +48,8 @@ export default defineConfig({
},
{
command: useStaticExport
? "npm run build && npm run serve:export"
: "npm run dev -- --hostname localhost --port 3300",
? `${nodeCommand} ${nextCommand} build && ${nodeCommand} ${exportServerCommand}`
: `${nodeCommand} ${nextCommand} dev --webpack --hostname localhost --port 3300`,
url: appUrl,
timeout: 120_000,
reuseExistingServer: reuseServers,
+33
View File
@@ -0,0 +1,33 @@
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const executableName = process.platform === "win32" ? "dotnet.exe" : "dotnet";
const candidates = [
process.env.DOTNET_HOST_PATH,
process.env.DOTNET_ROOT ? path.join(process.env.DOTNET_ROOT, executableName) : undefined,
path.join(os.homedir(), ".dotnet", executableName),
].filter(Boolean);
const dotnet = candidates.find((candidate) => existsSync(candidate)) ?? "dotnet";
const dotnetDirectory = path.dirname(dotnet);
const childEnv = {
...process.env,
DOTNET_HOST_PATH: dotnet,
PATH: dotnet === "dotnet"
? process.env.PATH
: `${dotnetDirectory}${path.delimiter}${process.env.PATH ?? ""}`,
};
function run(command, args) {
const result = spawnSync(command, args, { cwd: process.cwd(), env: childEnv, stdio: "inherit", shell: false });
if (result.error) {
console.error(`Unable to start ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
run(dotnet, ["build", "../JobTrackerApi/JobTrackerApi.csproj", "--configuration", "Release"]);
run(process.execPath, [path.resolve("node_modules/@playwright/test/cli.js"), "test", ...process.argv.slice(2)]);
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ApplicationChecklist from "./components/ApplicationChecklist";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
jest.mock("./api", () => ({
api: {
@@ -19,6 +20,8 @@ jest.mock("./api", () => ({
const mockedApi = api as jest.Mocked<typeof api>;
const renderChecklist = () => render(<I18nProvider><ApplicationChecklist jobId={7} /></I18nProvider>);
const item = (over: Partial<any> = {}) => ({
id: 1,
systemKey: "prepare-cv",
@@ -46,6 +49,7 @@ const checklist = (items: any[]) => ({
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.setItem("uiLanguage", "en");
mockedApi.get.mockResolvedValue({
data: checklist([
item(),
@@ -55,8 +59,17 @@ beforeEach(() => {
} as any);
});
test("localizes system checklist content without changing user-authored tasks", async () => {
window.localStorage.setItem("uiLanguage", "nb");
renderChecklist();
expect(await screen.findByText("Forbered en CV for denne stillingen")).toBeInTheDocument();
expect(screen.getByText("Dine egne oppgaver")).toBeInTheDocument();
expect(screen.getByText("Ask Sara for a referral")).toBeInTheDocument();
});
test("renders the checklist grouped by category with progress", async () => {
render(<ApplicationChecklist jobId={7} />);
renderChecklist();
expect(await screen.findByText("Prepare a CV for this role")).toBeInTheDocument();
expect(screen.getByText("Before applying")).toBeInTheDocument();
@@ -69,7 +82,7 @@ test("renders the checklist grouped by category with progress", async () => {
test("completing an item patches its status", async () => {
mockedApi.patch.mockResolvedValue({ data: item({ status: "done" }) } as any);
render(<ApplicationChecklist jobId={7} />);
renderChecklist();
fireEvent.click(await screen.findByRole("checkbox", { name: "Prepare a CV for this role" }));
await waitFor(() =>
@@ -79,7 +92,7 @@ test("completing an item patches its status", async () => {
test("un-ticking a completed item sends it back to pending", async () => {
mockedApi.patch.mockResolvedValue({ data: item({ id: 2, status: "pending" }) } as any);
render(<ApplicationChecklist jobId={7} />);
renderChecklist();
fireEvent.click(await screen.findByRole("checkbox", { name: "Create a cover letter" }));
await waitFor(() =>
@@ -89,7 +102,7 @@ test("un-ticking a completed item sends it back to pending", async () => {
test("adding a custom task posts the title", async () => {
mockedApi.post.mockResolvedValue({ data: item({ id: 9, systemKey: null, isSystemGenerated: false }) } as any);
render(<ApplicationChecklist jobId={7} />);
renderChecklist();
fireEvent.change(await screen.findByLabelText(/Add your own task/i), { target: { value: "Email the hiring manager" } });
fireEvent.click(screen.getByRole("button", { name: "Add" }));
@@ -102,7 +115,7 @@ test("adding a custom task posts the title", async () => {
test("reordering sends the new id order", async () => {
mockedApi.put.mockResolvedValue({ data: checklist([]) } as any);
render(<ApplicationChecklist jobId={7} />);
renderChecklist();
fireEvent.click(await screen.findByRole("button", { name: "Move down: Prepare a CV for this role" }));
await waitFor(() =>
@@ -112,7 +125,7 @@ test("reordering sends the new id order", async () => {
test("removing an item calls delete", async () => {
mockedApi.delete.mockResolvedValue({ data: undefined } as any);
render(<ApplicationChecklist jobId={7} />);
renderChecklist();
fireEvent.click(await screen.findByRole("button", { name: "Remove: Ask Sara for a referral" }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/checklist/3"));
@@ -64,6 +64,10 @@ const match = {
hasCareerProfile: true,
hasSelectedCv: true,
selectedCvName: "Backend CV",
jobLanguage: "en",
cvLanguage: "en",
languageMismatch: false,
usedTranslatedJobDescription: false,
matchedSkills: ["C#", "SQL"],
missingSkills: ["Kubernetes"],
relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 present", matched: ["C#"] }],
@@ -183,3 +187,19 @@ test("match warns when the advert is too short to score", async () => {
expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument();
});
test("match explains when a saved translation is used for a cross-language comparison", async () => {
mockedApi.get.mockResolvedValue({
data: {
...match,
jobLanguage: "nb",
cvLanguage: "en",
languageMismatch: true,
usedTranslatedJobDescription: true,
},
} as any);
render(<ApplicationMatch jobId={7} />);
expect(await screen.findByText(/uses the saved English translation/i)).toBeInTheDocument();
});
@@ -21,7 +21,7 @@ test('recruiter status suggestions require an explicit apply action', async () =
const applied = jest.fn();
mockedApi.get.mockResolvedValue({ data: { hasSuggestion: true, currentStatus: 'Applied', suggestedStatus: 'Interview' } } as any);
mockedApi.patch.mockResolvedValue({ data: {} } as any);
render(<ToastProvider><ApplicationStatusSuggestion jobId={42} onApplied={applied} /></ToastProvider>);
render(<I18nProvider><ToastProvider><ApplicationStatusSuggestion jobId={42} onApplied={applied} /></ToastProvider></I18nProvider>);
const button = await screen.findByRole('button', { name: 'Apply Interview' });
expect(mockedApi.patch).not.toHaveBeenCalled();
@@ -164,6 +164,10 @@ export type CareerMatch = {
aiSuggestionCount: number;
hasSelectedCv: boolean;
selectedCvName: string | null;
jobLanguage: "en" | "nb";
cvLanguage: "en" | "nb" | null;
languageMismatch: boolean;
usedTranslatedJobDescription: boolean;
};
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
@@ -50,11 +50,11 @@ afterEach(() => {
test("first-run workspace presents concise profile, import, general CV and job-specific actions", async () => {
renderOverview();
expect(screen.getByRole("heading", { name: "Career Workspace", level: 1 })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Overview", level: 2 })).toBeInTheDocument();
expect(await screen.findByText(/start by adding career information manually or importing a cv/i)).toBeInTheDocument();
expect(screen.queryByText(/job-specific CVs stay separate and never overwrite/i)).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Edit career profile" })).toHaveAttribute("href", "#career-profile-editor");
expect(screen.getAllByRole("link", { name: "Import a CV" })[0]).toHaveAttribute("href", "#career-cv-import");
expect(screen.getByRole("link", { name: "Edit career profile" })).toHaveAttribute("href", "/career?section=profile");
expect(screen.getAllByRole("link", { name: "Import a CV" })[0]).toHaveAttribute("href", "/career?section=import");
expect(screen.getByRole("link", { name: "Create a general CV" })).toHaveAttribute("href", "/career/builder");
expect(screen.getByRole("link", { name: "Choose a job" })).toHaveAttribute("href", "/jobs");
expect(await screen.findByText("No CV documents yet.")).toBeInTheDocument();
@@ -64,7 +64,7 @@ test("career workspace overview and import state render in Bokmål", async () =>
window.localStorage.setItem("uiLanguage", "nb");
renderOverview({ runs: [{ id: 7, status: "pending_review" }] });
expect(screen.getByRole("heading", { name: "Karriereområde", level: 1 })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Oversikt", level: 2 })).toBeInTheDocument();
expect(screen.getAllByText("Se gjennom importert CV", { selector: "a" })).toHaveLength(2);
expect(screen.getByText("Én import venter på godkjenning.")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Handlinger i karriereområdet" })).toBeInTheDocument();
@@ -94,7 +94,7 @@ test.each([
])("workspace exposes the %s import state as a resumable action", async (status, label, message) => {
renderOverview({ runs: [{ id: 13, status }] });
expect(screen.getAllByRole("link", { name: label })[0]).toHaveAttribute("href", "#career-cv-import");
expect(screen.getAllByRole("link", { name: label })[0]).toHaveAttribute("href", "/career?section=import");
expect(screen.getByText(message)).toBeInTheDocument();
await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1));
});
@@ -9,6 +9,7 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import {
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi,
} from "../applicationWorkspace";
@@ -20,6 +21,7 @@ import {
// scheduled...). Everything here is the user's to tick, add to, reorder or dismiss.
// docs/architecture/application-workspace.md.
export default function ApplicationChecklist({ jobId, onChanged }: { jobId: number; onChanged?: () => void }) {
const { t } = useI18n();
const [checklist, setChecklist] = useState<Checklist | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -30,9 +32,9 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
setChecklist(await applicationChecklistApi.get(jobId));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not load the checklist."));
setError(getApiErrorMessage(err, t("checklistLoadFailed")));
}
}, [jobId]);
}, [jobId, t]);
useEffect(() => {
load();
@@ -47,11 +49,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
await load();
onChanged?.();
} catch (err) {
setError(getApiErrorMessage(err, "Could not update the checklist."));
setError(getApiErrorMessage(err, t("checklistUpdateFailed")));
} finally {
setBusy(false);
}
}, [load, onChanged]);
}, [load, onChanged, t]);
const toggle = (item: ChecklistItem) =>
mutate(() => applicationChecklistApi.update(jobId, item.id, {
@@ -98,15 +100,15 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{progress && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Application checklist</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("checklistTitle")}</Typography>
<Typography variant="body2" color="text.secondary">
{progress.completed} of {progress.total} done
{t("checklistProgress", { completed: progress.completed, total: progress.total })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label="Checklist completion"
aria-label={t("checklistCompletion")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
@@ -114,9 +116,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{group.label}</Typography>
<Typography variant="overline" color="text.secondary">{checklistCategoryLabel(t, group.key)}</Typography>
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => (
{group.items.map((item) => {
const display = checklistItemDisplay(t, item);
return (
<Stack
key={item.id}
direction="row"
@@ -129,7 +133,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
checked={item.status === "done"}
disabled={busy}
onChange={() => toggle(item)}
inputProps={{ "aria-label": item.title }}
inputProps={{ "aria-label": display.title }}
sx={{ mt: -0.25 }}
/>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
@@ -142,40 +146,41 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
color: item.status === "done" ? "text.disabled" : "text.primary",
}}
>
{item.title}
{display.title}
</Typography>
{!item.isSystemGenerated && <Chip size="small" label="Yours" variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label="Detected" color="success" variant="outlined" />}
{!item.isSystemGenerated && <Chip size="small" label={t("checklistYours")} variant="outlined" />}
{item.isAutoCompleted && <Chip size="small" label={t("checklistDetected")} color="success" variant="outlined" />}
</Stack>
{item.description && (
<Typography variant="caption" color="text.secondary">{item.description}</Typography>
{display.description && (
<Typography variant="caption" color="text.secondary">{display.description}</Typography>
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title="Move up">
<Tooltip title={t("checklistMoveUp")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Move up: ${item.title}`} onClick={() => move(item, -1)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveUpItem", { title: display.title })} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Move down">
<Tooltip title={t("checklistMoveDown")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Move down: ${item.title}`} onClick={() => move(item, 1)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveDownItem", { title: display.title })} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? "Not relevant for this role" : "Delete"}>
<Tooltip title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<span>
<IconButton size="small" disabled={busy} aria-label={`Remove: ${item.title}`} onClick={() => remove(item)}>
<IconButton size="small" disabled={busy} aria-label={t("checklistRemoveItem", { title: display.title })} onClick={() => remove(item)}>
<DeleteOutlineIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
))}
);
})}
</Stack>
</Paper>
))}
@@ -185,14 +190,34 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
<TextField
fullWidth
size="small"
label="Add your own task"
label={t("checklistAddTask")}
value={draft}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>Add</Button>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>{t("checklistAdd")}</Button>
</Stack>
</Paper>
</Stack>
);
}
function checklistCategoryLabel(t: (key: any, vars?: Record<string, string | number>) => string, category: string) {
const keys: Record<string, string> = {
preparation: "checklistCategoryPreparation",
submission: "checklistCategorySubmission",
"follow-up": "checklistCategoryFollowUp",
interview: "checklistCategoryInterview",
custom: "checklistCategoryCustom",
};
return keys[category] ? t(keys[category]) : category;
}
function checklistItemDisplay(t: (key: any, vars?: Record<string, string | number>) => string, item: ChecklistItem) {
if (!item.systemKey || item.systemKey.startsWith("learning:")) return { title: item.title, description: item.description };
const token = item.systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
return {
title: t(`checklistItem_${token}`),
description: t(`checklistItem_${token}Description`),
};
}
@@ -302,6 +302,14 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
</Alert>
)}
{data?.languageMismatch && (
<Alert severity={data.usedTranslatedJobDescription ? "info" : "warning"} sx={{ borderRadius: 2 }}>
{t(data.usedTranslatedJobDescription
? "intelligenceMatchUsedTranslation"
: "intelligenceMatchLanguageMismatch")}
</Alert>
)}
<Chips label={t("intelligenceMatched")} values={data?.matchedSkills ?? []} color="success" />
<Chips label={t("intelligenceMissing")} values={data?.missingSkills ?? []} color="warning" />
@@ -4,6 +4,8 @@ import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "
import { api, getApiErrorMessage } from "../api";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
import type { FocusPlanResponse, StatusSuggestion, StrategySnapshotOperationResponse, UserOperation } from "../types";
import { useToast } from "../toast";
import { DraftCard, ListCard, TwoColumnSection } from "./JobDetailsPanels";
@@ -14,6 +16,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
const [suggestion, setSuggestion] = useState<StatusSuggestion | null>(null);
const [busy, setBusy] = useState(false);
const { toast } = useToast();
const { t } = useI18n();
useEffect(() => {
let active = true;
@@ -31,9 +34,9 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
await api.patch(`/jobapplications/${jobId}/status`, { status: suggestion.suggestedStatus });
setSuggestion(null);
onApplied();
toast("Application status updated from the latest message.", "success");
toast(t("workspaceStatusSuggestionApplied"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply the suggested status."), "error");
toast(getApiErrorMessage(error, t("workspaceStatusSuggestionFailed")), "error");
} finally {
setBusy(false);
}
@@ -42,9 +45,12 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
return (
<Alert
severity="info"
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>Apply {suggestion.suggestedStatus}</Button>}
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>{t("workspaceStatusSuggestionApply", { status: statusLabel(t, suggestion.suggestedStatus) })}</Button>}
>
A recent recruiter message suggests moving this application from {suggestion.currentStatus || "its current stage"} to {suggestion.suggestedStatus}.
{t("workspaceStatusSuggestionBody", {
current: suggestion.currentStatus ? statusLabel(t, suggestion.currentStatus) : t("workspaceStatusSuggestionCurrent"),
suggested: statusLabel(t, suggestion.suggestedStatus),
})}
</Alert>
);
}
@@ -52,6 +58,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { canUseAi } = useAccountPlan();
const { toast } = useToast();
const { t } = useI18n();
const [plan, setPlan] = useState<FocusPlanResponse | null>(null);
const [operation, setOperation] = useState<UserOperation | null>(null);
const [loading, setLoading] = useState(true);
@@ -95,10 +102,10 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
announced.current = key;
if (operation.status === "succeeded") {
void loadPlan();
toast("Strategy snapshot completed.", "success");
} else if (operation.status === "failed") toast("Strategy snapshot failed. You can retry safely.", "error");
else toast("Strategy snapshot cancelled.", "info");
}, [loadPlan, operation, toast]);
toast(t("workspaceStrategyCompleted"), "success");
} else if (operation.status === "failed") toast(t("workspaceStrategyFailed"), "error");
else toast(t("workspaceStrategyCancelled"), "info");
}, [loadPlan, operation, t, toast]);
const generate = async () => {
setLoading(true);
@@ -106,9 +113,9 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { data } = await api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: null });
announced.current = null;
setOperation(data.operation);
toast(data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info");
toast(data.created ? t("workspaceStrategyQueued") : t("workspaceStrategyAlreadyQueued"), "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not queue the strategy snapshot."), "error");
toast(getApiErrorMessage(error, t("workspaceStrategyQueueFailed")), "error");
} finally {
setLoading(false);
}
@@ -121,7 +128,7 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { data } = await api.post<UserOperation>(`/operations/${operation.id}/${action}`);
setOperation(data);
} catch (error) {
toast(getApiErrorMessage(error, `Could not ${action} the strategy snapshot.`), "error");
toast(getApiErrorMessage(error, action === "cancel" ? t("workspaceStrategyCancelFailed") : t("workspaceStrategyRetryFailed")), "error");
}
};
@@ -130,29 +137,29 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ mb: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Strategy snapshot</Typography>
<Typography variant="caption" color="text.secondary">An on-demand plan grounded in this advert and your saved career data.</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
</Box>
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
{!canUseAi ? "Pro required" : plan ? "Regenerate" : "Generate"}
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
</Button>
</Stack>
{operation && operation.status !== "succeeded" ? (
<Alert severity={operation.status === "failed" ? "error" : operation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }} action={<>
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>Cancel</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>Retry</Button> : null}
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>{t("cancel")}</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>{t("retry")}</Button> : null}
</>}>
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
</Alert>
) : null}
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : plan ? (
<Stack spacing={2}>
<DraftCard title="Strategic summary" content={plan.strategicSummary} />
<TwoColumnSection leftTitle="Immediate priorities" leftItems={plan.immediatePriorities} rightTitle="Proof points" rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle="CV bullet ideas" leftItems={plan.cvBulletIdeas} rightTitle="Cover letter angles" rightItems={plan.coverLetterAngles} />
<ListCard title="Follow-up approach" items={plan.followUpApproach} />
<DraftCard title={t("jobDetailsFocusSummary")} content={plan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={plan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={plan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={plan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={plan.followUpApproach} />
</Stack>
) : <Typography color="text.secondary">No strategy snapshot yet. Generate one when you want AI-assisted planning.</Typography>}
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
</Paper>
);
}
+95 -103
View File
@@ -104,7 +104,7 @@ function buildLinePath(values: number[], width: number, height: number) {
// theme.ts (see 02-dashboard.png).
function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: any }) {
return (
<Card sx={{ p: { xs: 1.5, sm: 2.25 }, ...sx }}>
<Card sx={{ p: "var(--app-card-padding)", ...sx }}>
{children}
</Card>
);
@@ -208,7 +208,7 @@ export default function DashboardView() {
const trendsView = useMemo(() => {
const appliedValues = analytics.map((x) => x.applied);
const responseValues = analytics.map((x) => x.responses);
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
const chartWidth = 860;
const chartHeight = isMobile ? 210 : 250;
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
@@ -280,29 +280,80 @@ export default function DashboardView() {
}, [navigate]);
const timeInStageMax = overview?.timeInStage?.length ? Math.max(...overview.timeInStage.map((item) => item.medianDays), 1) : 1;
const hasJobs = (stats?.total ?? 0) > 0;
const hasActivityData = analytics.some((item) => item.applied > 0 || item.responses > 0);
const funnelItems = (overview?.funnel ?? []).filter((item) => item.count > 0);
const hasStageData = Boolean(overview?.timeInStage?.length);
const hasCompanies = Boolean(overview?.topCompanies?.length);
const hasSkills = tags.length > 0;
const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 0.5 }}>{t("dashboardTodayTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardTodayBody")}</Typography>
{summaryView.priorityJobs.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
<Stack spacing={1.1}>
{summaryView.priorityJobs.map((job) => {
const action = getReminderAction(job);
const tone = reminderTone(job);
const toneColor = theme.palette[tone].main;
const urgent = tone === "error";
return (
<Box
key={job.id}
sx={{
p: 1.5,
pl: 2,
borderRadius: 2,
borderLeft: `4px solid ${toneColor}`,
backgroundColor: alpha(toneColor, 0.07),
display: "flex",
justifyContent: "space-between",
gap: 2,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} {job.jobTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
</Box>
<Button
variant="contained"
disableElevation
onClick={() => openReminderJob(job)}
sx={urgent
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
>
{action?.label ?? t("remindersOpen")}
</Button>
</Box>
);
})}
</Stack>
)}
<Box sx={{ mt: 1.5 }}>
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null;
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ maxWidth: 760 }}>
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
{t("dashboardHeroLabel")}
</Typography>
<Typography variant="h3" sx={{ mt: 0.5, color: "text.primary", overflowWrap: "anywhere" }}>
{t("dashboardOverviewTitle")}
</Typography>
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1 }}>
{t("dashboardOverviewBody")}
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2, flexWrap: "wrap" }}>
{hasJobs ? <Box sx={{ mb: 2, display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ flexWrap: "wrap" }}>
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
</Stack>
</Box>
</Stack>
<Box
sx={{
@@ -339,25 +390,27 @@ export default function DashboardView() {
))}
</Menu>
</Box>
</Box>
</Box> : null}
<ViewStateNotice
loading={summaryResource.loading}
error={summaryResource.error}
title="Unable to load dashboard summary"
description="The dashboard summary is unavailable right now."
title={t("dashboardSummaryUnavailableTitle")}
description={t("dashboardSummaryUnavailableBody")}
onRetry={summaryResource.reload}
/>
<ViewStateNotice
loading={trendsResource.loading}
error={trendsResource.error}
title="Unable to load dashboard trends"
description="Charts and trend panels could not reach the API."
title={t("dashboardTrendsUnavailableTitle")}
description={t("dashboardTrendsUnavailableBody")}
onRetry={trendsResource.reload}
compact
/>
{!summaryResource.loading && !summaryResource.error && prefs.cards ? (
{priorityPanel ? <Box sx={{ mt: 2 }}>{priorityPanel}</Box> : null}
{!summaryResource.loading && !summaryResource.error && hasJobs && prefs.cards ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}>
{metricCards.map((card) => (
<SectionCard
@@ -393,7 +446,7 @@ export default function DashboardView() {
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && prefs.activity ? (
{!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? (
<SectionCard>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box>
@@ -412,9 +465,9 @@ export default function DashboardView() {
</Stack>
</Box>
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
<Box sx={{ minWidth: trendsView.chartWidth }}>
<svg width={trendsView.chartWidth} height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`}>
<Box sx={{ mt: 2, minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<svg width="100%" height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`} preserveAspectRatio="none" style={{ display: "block", maxWidth: "100%" }}>
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
<line
key={tick}
@@ -441,10 +494,10 @@ export default function DashboardView() {
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error ? (
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
<SectionCard>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
{overview?.timeInStage?.length ? (
{overview?.timeInStage?.length ? (<>
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
{overview.timeInStage.map((item, index) => (
<Box key={item.stage}>
@@ -470,9 +523,7 @@ export default function DashboardView() {
</Box>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{t("dashboardNoTagsYet")}</Typography>
)}
</>) : null}
{tags.length ? (
<Box sx={{ mt: 2.25 }}>
@@ -490,9 +541,10 @@ export default function DashboardView() {
</Box>
) : null}
<Typography variant="h6" sx={{ mt: 2.25 }}>{t("dashboardConversionFunnelTitle")}</Typography>
{funnelItems.length > 0 ? <>
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
<Stack spacing={1.2} sx={{ mt: 1 }}>
{(overview?.funnel ?? []).map((item) => {
{funnelItems.map((item) => {
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
return (
<Box key={item.label}>
@@ -517,14 +569,15 @@ export default function DashboardView() {
);
})}
</Stack>
</> : null}
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
{summaryView.topSource ? <Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
</Typography>
</Box>
</Box> : null}
</SectionCard>
) : null}
</Box>
@@ -551,65 +604,8 @@ export default function DashboardView() {
</Box>
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
{summaryView.priorityJobs.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
<Stack spacing={1.1}>
{summaryView.priorityJobs.map((job) => {
const action = getReminderAction(job);
const tone = reminderTone(job);
const toneColor = theme.palette[tone].main;
const urgent = tone === "error";
return (
<Box
key={job.id}
sx={{
p: 1.5,
pl: 2,
borderRadius: 2,
borderLeft: `4px solid ${toneColor}`,
backgroundColor: alpha(toneColor, 0.07),
display: "flex",
justifyContent: "space-between",
gap: 2,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0, display: "flex", gap: 1.25, alignItems: "flex-start" }}>
<Box sx={{ width: 8, height: 8, mt: 0.75, borderRadius: "50%", backgroundColor: toneColor, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} {job.jobTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
</Box>
<Button
variant="contained"
disableElevation
onClick={() => openReminderJob(job)}
sx={urgent
? { width: { xs: "100%", sm: "auto" }, bgcolor: "text.primary", color: "background.paper", boxShadow: "none", "&:hover": { bgcolor: "text.primary", boxShadow: "none" } }
: { width: { xs: "100%", sm: "auto" }, bgcolor: alpha(toneColor, 0.15), color: toneColor, boxShadow: "none", "&:hover": { bgcolor: alpha(toneColor, 0.24), boxShadow: "none" } }}
>
{action?.label ?? t("remindersOpen")}
</Button>
</Box>
);
})}
</Stack>
)}
<Box sx={{ mt: 1.5 }}>
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
) : null}
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1fr 1fr" }, gap: 2, mt: 2 }}>
{!summaryResource.loading && !summaryResource.error && hasCompanies && prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
<Stack spacing={1.25}>
@@ -628,13 +624,10 @@ export default function DashboardView() {
</SectionCard>
) : null}
{!trendsResource.loading && !trendsResource.error && prefs.skills ? (
{!trendsResource.loading && !trendsResource.error && hasSkills && prefs.skills ? (
<SectionCard>
<Typography variant="h6" sx={{ mb: 1 }}>{t("dashboardTopSkills")}</Typography>
{tags.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography>
) : (
<Stack spacing={1.15}>
<Stack spacing={1.15}>
{tags.slice(0, 8).map((tag, index) => {
const max = Math.max(...tags.map((item) => item.count), 1);
const width = (tag.count / max) * 100;
@@ -650,8 +643,7 @@ export default function DashboardView() {
</Box>
);
})}
</Stack>
)}
</Stack>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
{!tagTrends || tagTrends.series.length === 0 ? (
@@ -1,13 +1,15 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import { Box, Button, IconButton, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CloseIcon from "@mui/icons-material/Close";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { getUserKeyFromToken } from "../themePrefs";
type MeResponse = {
email?: string | null;
@@ -35,6 +37,8 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const dismissalKey = `onboardingDismissed:${getUserKeyFromToken()}`;
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissalKey) === "true");
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
useEffect(() => {
@@ -68,7 +72,7 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
return () => { active = false; };
}, []);
if (!status) return null;
if (!status || dismissed) return null;
const steps = [
{ done: true, label: t("onboardingStepSignup"), actionLabel: "" },
@@ -83,7 +87,19 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
return (
<Paper sx={{ p: 2.25, mb: 2, borderRadius: 4, border: "1px solid", borderColor: alpha(theme.palette.primary.main, 0.25), background: alpha(theme.palette.primary.main, 0.04) }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<IconButton
size="small"
aria-label={t("onboardingDismiss")}
onClick={() => {
window.localStorage.setItem(dismissalKey, "true");
setDismissed(true);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
<LinearProgress variant="determinate" value={(completed / steps.length) * 100} sx={{ my: 1.5, borderRadius: 2 }} />
+21 -13
View File
@@ -47,7 +47,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Paper sx={{ p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
{children}
@@ -97,15 +97,23 @@ export default function SettingsView({
};
return (
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsSubtitle")}
</Typography>
<Paper sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", maxWidth: 960, mx: "auto" }}>
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
<InputLabel id="settings-section-label">{t("settingsNavigation")}</InputLabel>
<Select
labelId="settings-section-label"
value={tab}
label={t("settingsNavigation")}
onChange={(event) => setTab(Number(event.target.value))}
>
<MenuItem value={0}>{t("settingsTabGeneral")}</MenuItem>
<MenuItem value={1}>{t("settingsTabFollowUps")}</MenuItem>
<MenuItem value={2}>{t("settingsTabNotifications")}</MenuItem>
<MenuItem value={3}>{t("settingsTabBackup")}</MenuItem>
</Select>
</FormControl>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
@@ -197,8 +205,8 @@ export default function SettingsView({
<AiPrivacySettingsCard />
<AiUsageCard />
<QuickCaptureCard />
<SectionCard title="Connected accounts" subtitle="Manage inbox connections separately from your account and security settings.">
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>Manage connected accounts</Button>
<SectionCard title={t("connectedAccounts")} subtitle={t("settingsConnectedAccountsBody")}>
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>{t("settingsManageConnectedAccounts")}</Button>
</SectionCard>
</Box>
</TabPanel>
@@ -215,8 +223,8 @@ export default function SettingsView({
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
label={t("settingsNotificationsFollowUpReminders")}
/>
<Typography variant="caption" color="text.secondary">Disabling this prevents the background reminder worker from sending follow-up email to your account. In-app reminders remain available.</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? "Saving" : "Save notification settings"}</Button></Box>
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button></Box>
</Box>}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsNotificationsDelivery")}
+27 -1
View File
@@ -191,13 +191,39 @@ afterEach(() => {
test('dashboard attention card opens follow-up workspace', async () => {
renderLoop('/dashboard');
expect(await screen.findByText(/needs follow-up/i)).toBeInTheDocument();
expect(await screen.findByText(/^today$/i)).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs/42?section=communication&followMode=waiting-update'));
expect(screen.getByTestId('application-workspace')).toBeInTheDocument();
});
test('empty dashboard prioritizes onboarding and suppresses meaningless analytics', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: 0, active: 0, deleted: 0, byStatus: {}, appliedLast30Days: 0, averageDaysSinceApplied: 0 } } as any);
if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: Array.from({ length: 10 }, (_, index) => ({ label: `Stage ${index}`, count: 0 })), responseRateBySource: [], topCompanies: [], totalResponses: 0, totalActive: 0, timeInStage: [] } } as any);
if (url === '/jobapplications/reminders' || url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any);
return Promise.resolve({ data: {} } as any);
});
const view = renderLoop('/dashboard');
expect(await screen.findByText(/get set up/i)).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText(/application activity/i)).not.toBeInTheDocument());
expect(screen.queryByText(/conversion funnel/i)).not.toBeInTheDocument();
expect(screen.queryByText(/median time in stage/i)).not.toBeInTheDocument();
expect(screen.queryByText(/top companies by activity/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^top skills$/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /dismiss setup checklist/i }));
expect(screen.queryByText(/get set up/i)).not.toBeInTheDocument();
view.unmount();
renderLoop('/dashboard');
await waitFor(() => expect(screen.queryByText(/get set up/i)).not.toBeInTheDocument());
});
test('reminders open action routes tailored-cv gaps into the tailored cv workspace', async () => {
renderLoop('/reminders');
+182 -2
View File
@@ -11,6 +11,9 @@ export const translations = {
companies: "Companies",
trash: "Trash",
settings: "Settings",
openNavigation: "Open navigation",
expandSidebar: "Expand sidebar",
collapseSidebar: "Collapse sidebar",
addJob: "Add Job",
addJobApplication: "Add Job Application",
company: "Company",
@@ -159,6 +162,8 @@ export const translations = {
intelligenceCvProfileEmpty: "The linked CV cannot be matched until its career profile contains content.",
intelligenceMatchScore: "Career match score",
intelligenceAdvertTooShort: "The advert is too short to score reliably. Add the full text for a meaningful match.",
intelligenceMatchUsedTranslation: "The advert and CV use different languages. This match uses the saved English translation of the advert so equivalent wording is compared fairly.",
intelligenceMatchLanguageMismatch: "The advert and CV use different languages, but no saved translation is available. The score prioritises language-independent technical skills and may understate transferable experience.",
intelligenceMatched: "Matched",
intelligenceMissing: "Missing",
intelligenceRelevantExperience: "Relevant experience",
@@ -458,6 +463,10 @@ export const translations = {
cvDocumentAiNoChanges: "No wording changes were suggested.",
cvDocumentAiApplied: "The reviewed CV suggestions were applied.",
careerOverviewTitle: "Career Workspace",
careerWorkspaceNavigation: "Career workspace section",
careerWorkspaceOverviewTab: "Overview",
careerWorkspaceProfileTab: "Career profile",
careerWorkspaceImportTab: "Import review",
careerOverviewSubtitle: "Choose what you want to work on next.",
careerOverviewOpenBuilder: "Open CV Builder",
careerOverviewFirstRun: "Start by adding career information manually or importing a CV.",
@@ -655,10 +664,16 @@ export const translations = {
settingsBillingUnavailable: "Billing is temporarily unavailable.",
settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.",
settingsSubtitle: "Preferences and admin tools.",
settingsNavigation: "Settings section",
settingsTabGeneral: "General",
settingsTabFollowUps: "Follow-ups",
settingsTabNotifications: "Notifications",
settingsTabBackup: "Backup",
settingsConnectedAccountsBody: "Manage inbox connections separately from account and security settings.",
settingsManageConnectedAccounts: "Manage connected accounts",
settingsNotificationsEmailBody: "Turning this off stops follow-up reminder emails. In-app reminders remain available.",
settingsNotificationsSave: "Save notification settings",
settingsSaving: "Saving…",
settingsAppearance: "Appearance",
settingsTheme: "Theme",
settingsThemeSystem: "System",
@@ -914,6 +929,7 @@ export const translations = {
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingDismiss: "Dismiss setup checklist",
onboardingStepSignup: "Create your account",
onboardingStepVerify: "Verify your email",
onboardingStepProfile: "Complete your profile",
@@ -925,6 +941,12 @@ export const translations = {
onboardingStepEmail: "Connect your email",
onboardingStepEmailAction: "Connect email",
dashboardResponseRate: "{rate}% response rate",
dashboardTodayTitle: "Today",
dashboardTodayBody: "Your highest-priority application actions, ordered by urgency.",
dashboardSummaryUnavailableTitle: "Unable to load dashboard summary",
dashboardSummaryUnavailableBody: "The dashboard summary is unavailable right now.",
dashboardTrendsUnavailableTitle: "Unable to load dashboard trends",
dashboardTrendsUnavailableBody: "Charts and trend panels could not reach the API.",
dashboardMonthsShort: "{count} mo",
dashboardAppliedCount: "{count} applied",
dashboardResponsesCount: "{count} responses",
@@ -1242,7 +1264,7 @@ export const translations = {
adminSystemCpuMode: "CPU mode",
adminSystemNoSmtpHost: "No SMTP host configured",
googleAccountTitle: "Google account",
googleSetupHint: "Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleSetupHint: "Google sign-in is not available in this deployment.",
googleLinked: "Linked",
googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}",
@@ -1679,6 +1701,55 @@ export const translations = {
workspaceNoActivity: "No activity recorded yet.",
workspaceJobDetails: "Job details",
workspaceChecklist: "Next actions and checklist",
checklistTitle: "Application checklist",
checklistProgress: "{completed} of {total} done",
checklistCompletion: "Checklist completion",
checklistLoadFailed: "Could not load the checklist.",
checklistUpdateFailed: "Could not update the checklist.",
checklistCategoryPreparation: "Before applying",
checklistCategorySubmission: "Submitting",
checklistCategoryFollowUp: "Follow-up",
checklistCategoryInterview: "Interview",
checklistCategoryCustom: "Your own tasks",
checklistYours: "Yours",
checklistDetected: "Detected",
checklistMoveUp: "Move up",
checklistMoveDown: "Move down",
checklistMoveUpItem: "Move up: {title}",
checklistMoveDownItem: "Move down: {title}",
checklistNotRelevant: "Not relevant for this role",
checklistRemoveItem: "Remove: {title}",
checklistAddTask: "Add your own task",
checklistAdd: "Add",
deleteAction: "Delete",
checklistItem_reviewJobDetails: "Review job details",
checklistItem_reviewJobDetailsDescription: "Save the advert text so analysis and matching have something to work with.",
checklistItem_completeCareerProfile: "Complete your career profile",
checklistItem_completeCareerProfileDescription: "The master profile is what every CV variant is built from.",
checklistItem_prepareCv: "Prepare a CV for this role",
checklistItem_prepareCvDescription: "Attach a CV variant tailored to this application.",
checklistItem_reviewCvMatch: "Review the CV match",
checklistItem_reviewCvMatchDescription: "Check that the CV answers the advert before sending it.",
checklistItem_createCoverLetter: "Create a cover letter",
checklistItem_createCoverLetterDescription: "Prepare a tailored letter for this application.",
checklistItem_attachPortfolio: "Attach a portfolio example",
checklistItem_attachPortfolioDescription: "Add relevant work samples where the role rewards them.",
checklistItem_attachSupportingDocuments: "Attach supporting documents",
checklistItem_attachSupportingDocumentsDescription: "Add certificates, references, or transcripts where relevant.",
checklistItem_saveApplicationAnswers: "Save application answers for this role",
checklistItem_saveApplicationAnswersDescription: "Reuse them in the application form and interview preparation.",
checklistItem_captureRecruiterContact: "Capture recruiter contact details",
checklistItem_captureRecruiterContactDescription: "A named contact makes a focused follow-up possible.",
checklistItem_confirmSubmitted: "Confirm the application was submitted",
checklistItem_confirmSubmittedDescription: "Move it out of the prospect stage and record the application date.",
checklistItem_addFollowUpReminder: "Add a follow-up reminder",
checklistItem_addFollowUpReminderDescription: "Choose when you want to follow up on this application.",
checklistItem_setNextAction: "Write the next action",
checklistItem_setNextActionDescription: "Keep the application moving deliberately rather than drifting.",
checklistItem_prepareInterviewNotes: "Prepare interview notes",
checklistItem_prepareInterviewNotesDescription: "Record talking points and likely questions before the interview.",
checklistItem_researchCompany: "Research the company",
checklistItem_researchCompanyDescription: "Learn enough about its product, people, and recent work to ask useful questions.",
workspaceActivityHistory: "Activity history",
workspaceDocuments: "Documents",
workspaceCommunication: "Communication",
@@ -1686,6 +1757,25 @@ export const translations = {
workspaceSelectCvMatch: "Choose the CV intended for this application before comparing it with the advert.",
workspaceComparingCv: "{name} compared with this job advert.",
workspaceSelectCvFirst: "Select a CV on the CV tab first. The application will only analyse the document you explicitly link.",
workspaceStatusSuggestionApply: "Apply {status}",
workspaceStatusSuggestionBody: "A recent recruiter message suggests moving this application from {current} to {suggested}.",
workspaceStatusSuggestionCurrent: "its current stage",
workspaceStatusSuggestionApplied: "Application status updated from the latest message.",
workspaceStatusSuggestionFailed: "Could not apply the suggested status.",
workspaceStrategyTitle: "Strategy snapshot",
workspaceStrategySubtitle: "An on-demand plan grounded in this advert and your saved career data.",
workspaceStrategyProRequired: "Pro required",
workspaceStrategyGenerate: "Generate",
workspaceStrategyRegenerate: "Regenerate",
workspaceStrategyCompleted: "Strategy snapshot completed.",
workspaceStrategyFailed: "Strategy snapshot failed. You can retry safely.",
workspaceStrategyCancelled: "Strategy snapshot cancelled.",
workspaceStrategyQueued: "Strategy snapshot queued.",
workspaceStrategyAlreadyQueued: "Strategy snapshot is already queued.",
workspaceStrategyQueueFailed: "Could not queue the strategy snapshot.",
workspaceStrategyCancelFailed: "Could not cancel the strategy snapshot.",
workspaceStrategyRetryFailed: "Could not retry the strategy snapshot.",
workspaceStrategyEmpty: "No strategy snapshot yet. Generate one when you want AI-assisted planning.",
coverAiTitle: "AI writing assistant",
coverAiSubtitle: "Uses this job and its linked CV. Suggestions never overwrite your document.",
coverAiSelectCv: "Select a CV before generating a tailored cover letter.",
@@ -1707,6 +1797,9 @@ export const translations = {
companies: "Selskaper",
trash: "Papirkurv",
settings: "Innstillinger",
openNavigation: "Åpne navigasjon",
expandSidebar: "Utvid sidemenyen",
collapseSidebar: "Skjul sidemenyen",
addJob: "Legg til jobb",
addJobApplication: "Legg til jobbsøknad",
company: "Selskap",
@@ -1855,6 +1948,8 @@ export const translations = {
intelligenceCvProfileEmpty: "Den tilknyttede CV-en kan ikke sammenlignes før karriereprofilen inneholder data.",
intelligenceMatchScore: "Samsvar mellom CV og jobb",
intelligenceAdvertTooShort: "Annonsen er for kort til å gi en pålitelig vurdering. Legg til hele teksten for et meningsfylt samsvar.",
intelligenceMatchUsedTranslation: "Annonsen og CV-en bruker ulike språk. Sammenligningen bruker den lagrede engelske oversettelsen av annonsen, slik at tilsvarende formuleringer vurderes rettferdig.",
intelligenceMatchLanguageMismatch: "Annonsen og CV-en bruker ulike språk, men ingen lagret oversettelse er tilgjengelig. Vurderingen prioriterer språkuavhengige tekniske ferdigheter og kan undervurdere overførbar erfaring.",
intelligenceMatched: "Samsvarer",
intelligenceMissing: "Mangler",
intelligenceRelevantExperience: "Relevant erfaring",
@@ -2154,6 +2249,10 @@ export const translations = {
cvDocumentAiNoChanges: "Ingen tekstendringer ble foreslått.",
cvDocumentAiApplied: "De gjennomgåtte CV-forslagene ble tatt i bruk.",
careerOverviewTitle: "Karriereområde",
careerWorkspaceNavigation: "Område i karrierearbeidsområdet",
careerWorkspaceOverviewTab: "Oversikt",
careerWorkspaceProfileTab: "Karriereprofil",
careerWorkspaceImportTab: "Importgjennomgang",
careerOverviewSubtitle: "Velg hva du vil arbeide med videre.",
careerOverviewOpenBuilder: "Åpne CV-bygger",
careerOverviewFirstRun: "Start med å legge til karriereinformasjon manuelt eller importere en CV.",
@@ -2351,10 +2450,16 @@ export const translations = {
settingsBillingUnavailable: "Betaling er midlertidig utilgjengelig.",
settingsBillingNotConfigured: "Pro-oppgraderinger er ikke tilgjengelige i denne installasjonen ennå.",
settingsSubtitle: "Preferanser og adminverktøy.",
settingsNavigation: "Innstillingsområde",
settingsTabGeneral: "Generelt",
settingsTabFollowUps: "Oppfølging",
settingsTabNotifications: "Varsler",
settingsTabBackup: "Sikkerhetskopi",
settingsConnectedAccountsBody: "Administrer innbokstilkoblinger separat fra konto- og sikkerhetsinnstillingene.",
settingsManageConnectedAccounts: "Administrer tilkoblede kontoer",
settingsNotificationsEmailBody: "Hvis dette slås av, stoppes e-postpåminnelser om oppfølging. Påminnelser i appen forblir tilgjengelige.",
settingsNotificationsSave: "Lagre varslingsinnstillinger",
settingsSaving: "Lagrer…",
settingsAppearance: "Utseende",
settingsTheme: "Tema",
settingsThemeSystem: "System",
@@ -2610,6 +2715,7 @@ export const translations = {
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingDismiss: "Skjul oppstartslisten",
onboardingStepSignup: "Opprett kontoen din",
onboardingStepVerify: "Bekreft e-postadressen din",
onboardingStepProfile: "Fullfør profilen din",
@@ -2621,6 +2727,12 @@ export const translations = {
onboardingStepEmail: "Koble til e-post",
onboardingStepEmailAction: "Koble til e-post",
dashboardResponseRate: "{rate}% svarrate",
dashboardTodayTitle: "I dag",
dashboardTodayBody: "De viktigste handlingene for søknadene dine, sortert etter hvor mye de haster.",
dashboardSummaryUnavailableTitle: "Kunne ikke laste dashboard-oppsummeringen",
dashboardSummaryUnavailableBody: "Dashboard-oppsummeringen er ikke tilgjengelig akkurat nå.",
dashboardTrendsUnavailableTitle: "Kunne ikke laste dashboard-trender",
dashboardTrendsUnavailableBody: "Grafer og trendpaneler fikk ikke kontakt med API-et.",
dashboardMonthsShort: "{count} md",
dashboardAppliedCount: "{count} søkt",
dashboardResponsesCount: "{count} svar",
@@ -2938,7 +3050,7 @@ export const translations = {
adminSystemCpuMode: "CPU-modus",
adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert",
googleAccountTitle: "Google-konto",
googleSetupHint: "Sett `NEXT_PUBLIC_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleSetupHint: "Google-innlogging er ikke tilgjengelig i denne installasjonen.",
googleLinked: "Koblet",
googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}",
@@ -3375,6 +3487,55 @@ export const translations = {
workspaceNoActivity: "Ingen aktivitet er registrert ennå.",
workspaceJobDetails: "Stillingsdetaljer",
workspaceChecklist: "Neste handlinger og sjekkliste",
checklistTitle: "Sjekkliste for søknaden",
checklistProgress: "{completed} av {total} fullført",
checklistCompletion: "Fullføring av sjekklisten",
checklistLoadFailed: "Kunne ikke laste sjekklisten.",
checklistUpdateFailed: "Kunne ikke oppdatere sjekklisten.",
checklistCategoryPreparation: "Før du søker",
checklistCategorySubmission: "Innsending",
checklistCategoryFollowUp: "Oppfølging",
checklistCategoryInterview: "Intervju",
checklistCategoryCustom: "Dine egne oppgaver",
checklistYours: "Din",
checklistDetected: "Oppdaget",
checklistMoveUp: "Flytt opp",
checklistMoveDown: "Flytt ned",
checklistMoveUpItem: "Flytt opp: {title}",
checklistMoveDownItem: "Flytt ned: {title}",
checklistNotRelevant: "Ikke relevant for denne stillingen",
checklistRemoveItem: "Fjern: {title}",
checklistAddTask: "Legg til din egen oppgave",
checklistAdd: "Legg til",
deleteAction: "Slett",
checklistItem_reviewJobDetails: "Se gjennom stillingsdetaljene",
checklistItem_reviewJobDetailsDescription: "Lagre annonseteksten slik at analyse og sammenligning har et godt grunnlag.",
checklistItem_completeCareerProfile: "Fullfør karriereprofilen din",
checklistItem_completeCareerProfileDescription: "Hovedprofilen er grunnlaget for alle CV-varianter.",
checklistItem_prepareCv: "Forbered en CV for denne stillingen",
checklistItem_prepareCvDescription: "Koble til en CV-variant som er tilpasset denne søknaden.",
checklistItem_reviewCvMatch: "Se gjennom CV-samsvaret",
checklistItem_reviewCvMatchDescription: "Kontroller at CV-en svarer på annonsen før du sender den.",
checklistItem_createCoverLetter: "Opprett et søknadsbrev",
checklistItem_createCoverLetterDescription: "Forbered et tilpasset brev for denne søknaden.",
checklistItem_attachPortfolio: "Legg ved et porteføljeeksempel",
checklistItem_attachPortfolioDescription: "Legg til relevante arbeidseksempler når stillingen verdsetter dem.",
checklistItem_attachSupportingDocuments: "Legg ved støttedokumenter",
checklistItem_attachSupportingDocumentsDescription: "Legg til attester, referanser eller vitnemål der det er relevant.",
checklistItem_saveApplicationAnswers: "Lagre søknadssvar for denne stillingen",
checklistItem_saveApplicationAnswersDescription: "Gjenbruk dem i søknadsskjemaet og intervjuforberedelsen.",
checklistItem_captureRecruiterContact: "Lagre kontaktinformasjon til rekruttereren",
checklistItem_captureRecruiterContactDescription: "En navngitt kontakt gjør målrettet oppfølging mulig.",
checklistItem_confirmSubmitted: "Bekreft at søknaden er sendt",
checklistItem_confirmSubmittedDescription: "Flytt den ut av interessentstadiet og registrer søknadsdatoen.",
checklistItem_addFollowUpReminder: "Legg til en oppfølgingspåminnelse",
checklistItem_addFollowUpReminderDescription: "Velg når du vil følge opp denne søknaden.",
checklistItem_setNextAction: "Skriv neste handling",
checklistItem_setNextActionDescription: "Hold søknaden i bevegelse med en tydelig plan.",
checklistItem_prepareInterviewNotes: "Forbered intervjunotater",
checklistItem_prepareInterviewNotesDescription: "Noter samtalepunkter og sannsynlige spørsmål før intervjuet.",
checklistItem_researchCompany: "Undersøk selskapet",
checklistItem_researchCompanyDescription: "Lær nok om produktet, menneskene og nylig arbeid til å stille nyttige spørsmål.",
workspaceActivityHistory: "Aktivitetshistorikk",
workspaceDocuments: "Dokumenter",
workspaceCommunication: "Kommunikasjon",
@@ -3382,6 +3543,25 @@ export const translations = {
workspaceSelectCvMatch: "Velg CV-en som skal brukes i søknaden før den sammenlignes med annonsen.",
workspaceComparingCv: "{name} sammenlignes med denne stillingsannonsen.",
workspaceSelectCvFirst: "Velg først en CV under CV-fanen. Søknaden analyserer bare dokumentet du kobler til eksplisitt.",
workspaceStatusSuggestionApply: "Bruk {status}",
workspaceStatusSuggestionBody: "En nylig melding fra en rekrutterer foreslår å flytte denne søknaden fra {current} til {suggested}.",
workspaceStatusSuggestionCurrent: "nåværende stadium",
workspaceStatusSuggestionApplied: "Søknadsstatusen ble oppdatert fra den siste meldingen.",
workspaceStatusSuggestionFailed: "Kunne ikke bruke den foreslåtte statusen.",
workspaceStrategyTitle: "Strategioversikt",
workspaceStrategySubtitle: "En behovsstyrt plan basert på denne annonsen og dine lagrede karrieredata.",
workspaceStrategyProRequired: "Krever Pro",
workspaceStrategyGenerate: "Generer",
workspaceStrategyRegenerate: "Generer på nytt",
workspaceStrategyCompleted: "Strategioversikten er ferdig.",
workspaceStrategyFailed: "Strategioversikten mislyktes. Du kan trygt prøve igjen.",
workspaceStrategyCancelled: "Strategioversikten ble avbrutt.",
workspaceStrategyQueued: "Strategioversikten er satt i kø.",
workspaceStrategyAlreadyQueued: "Strategioversikten står allerede i kø.",
workspaceStrategyQueueFailed: "Kunne ikke sette strategioversikten i kø.",
workspaceStrategyCancelFailed: "Kunne ikke avbryte strategioversikten.",
workspaceStrategyRetryFailed: "Kunne ikke prøve strategioversikten på nytt.",
workspaceStrategyEmpty: "Ingen strategioversikt ennå. Generer en når du ønsker AI-støttet planlegging.",
coverAiTitle: "AI-skriveassistent",
coverAiSubtitle: "Bruker denne stillingen og den tilknyttede CV-en. Forslag overskriver aldri dokumentet ditt.",
coverAiSelectCv: "Velg en CV før du genererer et skreddersydd søknadsbrev.",
+5 -5
View File
@@ -295,7 +295,7 @@ export default function AppShell({
edge="start"
size="small"
color="secondary"
aria-label="Open navigation"
aria-label={t("openNavigation")}
onClick={() => onToggleDrawer(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
@@ -371,8 +371,8 @@ export default function AppShell({
size="small"
color="secondary"
onClick={() => setDesktopNavCollapsed((value) => !value)}
title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
title={desktopNavCollapsed ? t("expandSidebar") : t("collapseSidebar")}
aria-label={desktopNavCollapsed ? t("expandSidebar") : t("collapseSidebar")}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
>
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
@@ -521,7 +521,7 @@ export default function AppShell({
component="main"
sx={{
flex: 1,
p: { xs: 2, sm: 3 },
p: "var(--app-page-gutter)",
bgcolor: "background.default",
minHeight: "100vh",
minWidth: 0,
@@ -529,7 +529,7 @@ export default function AppShell({
>
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, minWidth: 0 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
{breadcrumbs.map((c) => (
+33 -13
View File
@@ -78,7 +78,8 @@ function renderWith(Component: React.ComponentType) {
// These exercise the master-CV editing surface, which lives on /career (CareerProfilePage) after
// the Phase 2.2 split. ProfilePage no longer carries it.
function renderPage() {
function renderPage(section: 'profile' | 'import' = 'profile') {
window.history.replaceState({}, '', `/career?section=${section}`);
return renderWith(CareerProfilePage);
}
void ProfilePage;
@@ -184,6 +185,7 @@ beforeEach(() => {
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
window.localStorage.clear();
window.history.replaceState({}, '', '/career');
});
afterEach(() => {
@@ -196,18 +198,19 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/profile sections/i)).toBeInTheDocument();
expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0);
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
expect(screen.getByText(/block-1/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /import review/i }));
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
expect(screen.getByText(/current run/i)).toBeInTheDocument();
expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /open cv builder/i })).toHaveAttribute('href', '/career/builder');
expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0);
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
expect(screen.getByText(/block-1/i)).toBeInTheDocument();
fireEvent.click(originalExtractionToggle);
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
@@ -273,7 +276,7 @@ test('editing a field in an extracted section updates parent state and flows int
});
test('profile page can reprocess from stored artifact history', async () => {
renderPage();
renderPage('import');
expect(await screen.findByText(/extraction history/i)).toBeInTheDocument();
const reprocessButton = screen.getByRole('button', { name: /reprocess cv/i });
@@ -354,7 +357,7 @@ test('profile page shows durable CV operation state and retries a failed run', a
canRetry: true,
},
}];
renderPage();
renderPage('import');
expect(await screen.findByText('failed')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /retry processing/i }));
@@ -365,10 +368,9 @@ test('profile page shows durable CV operation state and retries a failed run', a
});
test('profile page keeps raw extraction collapsed until expanded', async () => {
renderPage();
renderPage('import');
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument();
expect(await screen.findByText(/your career information stays front and center/i)).toBeInTheDocument();
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
@@ -385,7 +387,7 @@ test('saving the master profile (career) persists the structured profile via /ca
// Phase 3: /career saves the master profile through the relational API. The payload carries the
// structured profile object (not the legacy flat blob) and never identity fields.
mockedApi.put.mockResolvedValue({ data: { profile: structuredCv, completeness: { percent: 70, missing: [], sections: [] }, cvText: '' } } as any);
renderWith(CareerProfilePage);
renderPage();
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
const fullNameInput = screen.getByLabelText(/full name/i);
@@ -411,7 +413,25 @@ test('saving the master profile (career) persists the structured profile via /ca
test('/career shows the profile completeness overview', async () => {
renderWith(CareerProfilePage);
expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument();
expect(screen.getByText(/70%/)).toBeInTheDocument();
expect(await screen.findByText(/70%/)).toBeInTheDocument();
});
test('/career separates overview, profile and import while preserving unsaved profile edits', async () => {
renderWith(CareerProfilePage);
await screen.findByText(/70%/);
fireEvent.click(screen.getByRole('link', { name: /edit career profile/i }));
const nameField = await screen.findByLabelText(/full name/i);
fireEvent.change(nameField, { target: { value: 'Unsaved Section Switch' } });
fireEvent.click(screen.getByRole('tab', { name: /import review/i }));
expect(screen.getByRole('heading', { name: /master cv/i })).toBeInTheDocument();
expect(window.location.search).toBe('?section=import');
fireEvent.click(screen.getByRole('tab', { name: /career profile/i }));
expect(await screen.findByLabelText(/full name/i)).toHaveValue('Unsaved Section Switch');
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
expect(window.location.search).toBe('?section=profile');
});
test('pending CV extraction can be reviewed and applied', async () => {
@@ -426,7 +446,7 @@ test('pending CV extraction can be reviewed and applied', async () => {
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
}];
renderPage();
renderPage('import');
expect(await screen.findByText(/3 additions/i)).toBeInTheDocument();
const lowConfidence = screen.getByRole('checkbox', { name: /include low-confidence languages: french/i });
@@ -105,6 +105,7 @@ test('settings view has no accent picker and uses one follow-up section, one not
renderView();
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/settings section/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
+15
View File
@@ -250,9 +250,24 @@ export const getTheme = (_mode: "light" | "dark") => {
theme.components = {
MuiCssBaseline: {
styleOverrides: {
":root": {
"--app-page-gutter": "16px",
"--app-section-gap": "24px",
"--app-card-padding": "16px",
},
"@media (min-width: 768px)": {
":root": {
"--app-page-gutter": "24px",
"--app-card-padding": "20px",
},
},
"@media (min-width: 1440px)": {
":root": { "--app-page-gutter": "32px" },
},
body: {
WebkitFontSmoothing: "antialiased",
MozOsxFontSmoothing: "grayscale",
overflowX: "hidden",
},
// A global floor under every animation added this session (card hover-lift,
// MUI's own Dialog/Menu/Collapse transitions, ripple) -- users who set this OS/
@@ -333,7 +333,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
</Alert>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1.5 }}>
{stats.map((s) => (
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
onClick={() => onGo(s.go)}
+69 -15
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, Divider, FormControlLabel, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Paper, Select, Tab, Tabs, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
@@ -137,6 +137,13 @@ type CareerSectionStatus = { key: string; label: string; complete: boolean; coun
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
type CareerWorkspaceSection = "overview" | "profile" | "import";
function initialWorkspaceSection(): CareerWorkspaceSection {
if (typeof window === "undefined") return "overview";
const section = new URLSearchParams(window.location.search).get("section");
return section === "profile" || section === "import" ? section : "overview";
}
// CareerProfilePage backs /career: the master career profile — the single editable source of
// truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the
@@ -169,6 +176,16 @@ export default function CareerProfilePage() {
const [versions, setVersions] = useState<CareerVersion[]>([]);
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => {
setWorkspaceSection(next);
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (next === "overview") url.searchParams.delete("section");
else url.searchParams.set("section", next);
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
}, []);
const loadVersions = useCallback(async () => {
try {
@@ -303,16 +320,51 @@ export default function CareerProfilePage() {
const latestRun = extractionRuns[0];
return (
<Box sx={{ display: "grid", gap: 2 }}>
<CareerWorkspaceOverview completeness={completeness} runs={extractionRuns} loading={loading} loadError={loadError} />
<Paper id="career-profile-editor" sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
<ProfileCompleteness
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, p: 1.25 }}>
<InputLabel id="career-workspace-section-label" sx={{ ml: 1.25 }}>{t("careerWorkspaceNavigation")}</InputLabel>
<Select
labelId="career-workspace-section-label"
value={workspaceSection}
label={t("careerWorkspaceNavigation")}
onChange={(event) => navigateWorkspace(event.target.value as CareerWorkspaceSection)}
>
<MenuItem value="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
</Select>
</FormControl>
<Tabs
value={workspaceSection}
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
aria-label={t("careerWorkspaceNavigation")}
sx={{ display: { xs: "none", sm: "flex" }, px: 1 }}
>
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
<Tab value="import" label={t("careerWorkspaceImportTab")} />
</Tabs>
</Paper>
{workspaceSection === "overview" ? (
<CareerWorkspaceOverview
completeness={completeness}
runs={extractionRuns}
loading={loading}
loadError={loadError}
onNavigate={navigateWorkspace}
/>
) : null}
{workspaceSection !== "overview" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
{workspaceSection === "profile" ? <ProfileCompleteness
completeness={completeness}
versions={versions}
loading={loading}
onRestore={(version) => void restoreVersion(version)}
showSummary={false}
/>
<CropImageDialog
/> : null}
{workspaceSection === "profile" ? <CropImageDialog
open={cropOpen}
file={avatarFile}
onClose={() => {
@@ -338,7 +390,7 @@ export default function CareerProfilePage() {
setUploadingAvatar(false);
}
}}
/>
/> : null}
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
@@ -347,7 +399,7 @@ export default function CareerProfilePage() {
</Alert>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
@@ -395,7 +447,7 @@ export default function CareerProfilePage() {
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
{careerOnly ? "Career profile" : t("profileTitle")}
{careerOnly ? t("careerWorkspaceProfileTab") : t("profileTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
@@ -406,13 +458,14 @@ export default function CareerProfilePage() {
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
</Box>
</Box>
</Box> : null}
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
<Box sx={{ gridColumn: "1 / -1", p: workspaceSection === "import" ? { xs: 1.5, sm: 2 } : 0, borderRadius: 3, border: workspaceSection === "import" ? "1px solid" : "none", borderColor: "divider", backgroundColor: workspaceSection === "import" ? "background.default" : "transparent", display: careerOnly ? "block" : "none" }}>
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
{!canUseAi && (
<Box sx={{ mb: 2 }}>
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
@@ -710,7 +763,8 @@ export default function CareerProfilePage() {
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureEmpty")}</Typography>
)}
</Box>
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
<Box sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
@@ -738,7 +792,7 @@ export default function CareerProfilePage() {
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
</Typography>
@@ -748,7 +802,7 @@ export default function CareerProfilePage() {
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
<Button
variant="contained"
@@ -779,7 +833,7 @@ export default function CareerProfilePage() {
</Box>
</Paper>
</Paper> : null}
</Box>
);
}
@@ -30,12 +30,13 @@ function isActive(run: CareerWorkspaceImportRun) {
: run.status === "queued" || run.status === "running";
}
function ActionCard({ title, body, href, label, icon }: {
function ActionCard({ title, body, href, label, icon, onNavigate }: {
title: string;
body: string;
href: string;
label: string;
icon: React.ReactNode;
onNavigate?: () => void;
}) {
return (
<Paper variant="outlined" component="article" sx={{ p: 2, borderRadius: 3, display: "flex", flexDirection: "column", gap: 1.25 }}>
@@ -44,16 +45,24 @@ function ActionCard({ title, body, href, label, icon }: {
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{title}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{body}</Typography>
<Button href={href} variant="text" sx={{ alignSelf: "flex-start", px: 0.5 }}>{label}</Button>
<Button
href={href}
onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate(); } : undefined}
variant="text"
sx={{ alignSelf: "flex-start", px: 0.5 }}
>
{label}
</Button>
</Paper>
);
}
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError }: {
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate }: {
completeness: CareerWorkspaceCompleteness | null;
runs: CareerWorkspaceImportRun[];
loading: boolean;
loadError: string | null;
onNavigate?: (section: "profile" | "import") => void;
}) {
const { language, t } = useI18n();
const [recentCvs, setRecentCvs] = useState<CvVariantSummary[]>([]);
@@ -93,11 +102,11 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
const isFirstRun = !loading && !recentLoading && !loadError && (completeness?.percent ?? 0) === 0 && runs.length === 0 && recentCvs.length === 0;
return (
<Box component="section" aria-labelledby="career-workspace-title" sx={{ display: "grid", gap: 2 }}>
<Box component="section" aria-labelledby="career-workspace-overview-title" sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography id="career-workspace-title" component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("careerOverviewTitle")}</Typography>
<Typography id="career-workspace-overview-title" component="h2" variant="h5" sx={{ fontWeight: 900 }}>{t("careerWorkspaceOverviewTab")}</Typography>
<Typography color="text.secondary">{t("careerOverviewSubtitle")}</Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
@@ -116,20 +125,20 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
{!loading && completeness?.missing.length ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })}</Typography>
) : null}
<Button href="#career-profile-editor" size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
<Button href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
</Box>
<Alert severity={importState.severity} sx={{ borderRadius: 3, alignItems: "flex-start" }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCvImport")}</Typography>
<Typography variant="body2">{importState.message}</Typography>
<Button href="#career-cv-import" color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
<Button href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
</Alert>
</Box>
</Paper>
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="#career-profile-editor" label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="#career-cv-import" label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importState.label} icon={<UploadFileOutlinedIcon />} />
<ActionCard title={t("careerOverviewGeneralCv")} body={t("careerOverviewGeneralCvBody")} href="/career/builder" label={t("careerOverviewCreateGeneralCv")} icon={<DescriptionOutlinedIcon />} />
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
</Box>