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:
@@ -395,4 +395,29 @@ public sealed class ApplicationIntelligenceTests
|
||||
// Reading the match must not itself call the AI or append history.
|
||||
Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_uses_saved_English_translation_when_linked_cv_is_English()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1", j =>
|
||||
{
|
||||
j.Description = "Vi ser etter en utvikler med erfaring fra C# og skalerbare systemer.";
|
||||
j.DescriptionLanguage = "nb-NO";
|
||||
j.TranslatedDescription = "We need a developer with C# and scalable systems experience.";
|
||||
});
|
||||
await SeedProfileAsync(db, "user-1");
|
||||
await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings { Language = "en" });
|
||||
|
||||
var match = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal("nb", match!.JobLanguage);
|
||||
Assert.Equal("en", match.CvLanguage);
|
||||
Assert.True(match.LanguageMismatch);
|
||||
Assert.True(match.UsedTranslatedJobDescription);
|
||||
Assert.Contains("C#", match.MatchedSkills);
|
||||
Assert.DoesNotContain(match.MissingSkills, value => value.Equals("utvikler", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,15 @@ public sealed class JobCvMatchServiceTests
|
||||
"Senior systemutvikler",
|
||||
"Vi ser etter deg som har erfaring med C# og ASP.NET Core. Du vil designe skalerbare distribuerte systemer. Gode samarbeidsevner er en fordel.",
|
||||
new[] { "C#", "ASP.NET Core", "designe skalerbare distribuerte systemer" },
|
||||
new[] { "med", "til", "for", "som", "erfaring", "ser" },
|
||||
new[] { "med", "til", "for", "som", "erfaring", "ser", "både", "del", "dnbs", "faglig", "får", "god", "hos", "utvikling" },
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"Norwegian recruitment filler",
|
||||
"Systemutvikler",
|
||||
"Hos DNBs faglige miljø får du både være del av god utvikling og bidra til moderne løsninger med C# og Azure.",
|
||||
new[] { "C#", "Azure" },
|
||||
new[] { "både", "del", "dnbs", "faglig", "får", "god", "hos", "utvikling", "bidra", "løsninger" },
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
|
||||
@@ -51,7 +51,11 @@ public sealed record CareerMatchDto(
|
||||
IReadOnlyList<string> Suggestions,
|
||||
int AiSuggestionCount,
|
||||
bool HasSelectedCv = false,
|
||||
string? SelectedCvName = null);
|
||||
string? SelectedCvName = null,
|
||||
string JobLanguage = "en",
|
||||
string? CvLanguage = null,
|
||||
bool LanguageMismatch = false,
|
||||
bool UsedTranslatedJobDescription = false);
|
||||
|
||||
public interface IApplicationIntelligenceService
|
||||
{
|
||||
@@ -259,7 +263,24 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
||||
["Skills"] = skillsText,
|
||||
};
|
||||
|
||||
var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections);
|
||||
var cvCorpus = string.Join("\n", sections.Values);
|
||||
var jobLanguage = NormalizeMatchLanguage(job.DescriptionLanguage, job.Description);
|
||||
var cvLanguage = NormalizeMatchLanguage(settings.Language, cvCorpus);
|
||||
var languageMismatch = !string.Equals(jobLanguage, cvLanguage, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Imported Norwegian adverts may already carry a trusted English translation. When the
|
||||
// explicitly linked CV is English, compare against that saved translation instead of
|
||||
// penalising equivalent wording in different languages. This endpoint remains read-only:
|
||||
// it never starts a translation request or mutates either source document.
|
||||
var useTranslatedJobDescription = languageMismatch
|
||||
&& jobLanguage == "nb"
|
||||
&& cvLanguage == "en"
|
||||
&& !string.IsNullOrWhiteSpace(job.TranslatedDescription);
|
||||
var matchDescription = useTranslatedJobDescription
|
||||
? job.TranslatedDescription!
|
||||
: job.Description ?? string.Empty;
|
||||
|
||||
var result = _match.Evaluate(job.JobTitle, matchDescription, sections);
|
||||
|
||||
var relevantExperience = experiences
|
||||
.Select(e => new { Entry = e, Override = Override(settings, e.ItemKey) })
|
||||
@@ -297,7 +318,11 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
||||
Suggestions: Suggestions(result, relevantExperience.Count),
|
||||
AiSuggestionCount: aiCount,
|
||||
HasSelectedCv: true,
|
||||
SelectedCvName: attached.Name);
|
||||
SelectedCvName: attached.Name,
|
||||
JobLanguage: jobLanguage,
|
||||
CvLanguage: cvLanguage,
|
||||
LanguageMismatch: languageMismatch,
|
||||
UsedTranslatedJobDescription: useTranslatedJobDescription);
|
||||
}
|
||||
|
||||
// Suggestions describe what the USER could change. They never edit anything themselves.
|
||||
@@ -411,4 +436,16 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
||||
|
||||
private static string? Blank(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string NormalizeMatchLanguage(string? declaredLanguage, string? text)
|
||||
{
|
||||
var language = string.IsNullOrWhiteSpace(declaredLanguage)
|
||||
? LanguageDetector.Detect(text)
|
||||
: declaredLanguage.Trim().ToLowerInvariant();
|
||||
return language.StartsWith("nb", StringComparison.Ordinal)
|
||||
|| language.StartsWith("no", StringComparison.Ordinal)
|
||||
|| language.StartsWith("nn", StringComparison.Ordinal)
|
||||
? "nb"
|
||||
: "en";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,9 @@ namespace JobTrackerApi.Services
|
||||
"mot", "uten", "hvordan", "ingen", "din", "ditt", "blir", "samme", "hvilken", "hvilke",
|
||||
"erfaring", "erfaringer", "kvalifikasjoner", "arbeidsoppgaver", "stilling", "stillingen",
|
||||
"søker", "ser", "ønsker", "mulighet", "spennende", "arbeidsmiljø", "selskap", "bedrift", "kandidat",
|
||||
"relevant", "fordel", "gode", "dyktig", "sammen",
|
||||
"relevant", "fordel", "gode", "god", "godt", "dyktig", "sammen", "både", "del", "dnbs",
|
||||
"faglig", "faglige", "får", "få", "hos", "utvikling", "ansvar", "ansvarlig", "bidra",
|
||||
"tilbyr", "kollega", "kollegaer", "løsning", "løsninger",
|
||||
// Common source-page chrome and consent text must never become tailoring advice.
|
||||
"cookie", "cookies", "privacy", "terms", "conditions", "menu", "home", "login", "contact",
|
||||
"website", "settings", "navigation", "jobs", "apply", "application", "share", "save", "accept",
|
||||
|
||||
@@ -1,6 +1,70 @@
|
||||
# JobTracker master programme progress
|
||||
|
||||
Updated: 2026-08-15
|
||||
Updated: 2026-08-29
|
||||
|
||||
## 2026-08-29 audit implementation programme
|
||||
|
||||
### Completed
|
||||
|
||||
- Introduced shared application spacing tokens for 16px mobile gutters, 24px tablet/desktop gutters, 32px wide-screen gutters, 24px section rhythm, and 16–20px card padding. `AppShell` and the first redesigned surfaces consume these tokens.
|
||||
- Reworked the Dashboard around today's prioritized actions. Empty accounts no longer render zero-value metrics, ten empty funnel stages, empty time-in-stage, company, skill, or activity panels. The activity SVG now scales to its container instead of requiring a clipped fixed-width mobile canvas.
|
||||
- Made onboarding dismissible per account/browser while preserving the checklist until it is completed or dismissed.
|
||||
- Made Settings use a compact section selector at mobile widths, kept tabs for larger screens, narrowed the content column, and removed its duplicate in-page title. Newly touched settings copy is localized in English and Norwegian Bokmål.
|
||||
- Replaced the ordinary-user Google configuration variable instruction with a human-readable unavailable state.
|
||||
- Made Playwright independent of nested `npm` calls and accidental system `.NET` ordering. The E2E launcher resolves `DOTNET_HOST_PATH`, `DOTNET_ROOT`, or the user-local SDK before falling back to PATH and invokes Next/Playwright through the current Node runtime.
|
||||
- Made application CV matching language-aware. It detects/normalizes the advert and linked-CV languages, uses an already-saved English advert translation for an English CV when available, never starts a translation or mutates either document during a match read, and tells the user when a cross-language comparison is translated or limited.
|
||||
- Expanded the shared deterministic keyword filter to remove Norwegian recruitment filler including `både`, `del`, `dnbs`, `faglig`, `får`, `god`, `hos`, and `utvikling` instead of presenting those words as CV gaps.
|
||||
- Split the Career Workspace into Overview, Career Profile, and Import Review destinations. Navigation is URL-backed, uses tabs above mobile widths and a labelled select at 375px, and preserves unsaved editor state while changing sections. Import diagnostics no longer force users to scroll through the normal profile editor.
|
||||
- Removed the duplicate Career Workspace title inside the Overview content; the shared application shell remains the single page-level heading.
|
||||
- Forced local Next development and Playwright's non-export server onto webpack after reproducing a Next 16 Turbopack panic (`Next.js package not found`) with the bundled portable Node runtime.
|
||||
- Localized the Job Workspace recruiter-status suggestion and strategy snapshot end to end, including status labels, action buttons, empty/loading outcomes, queued-operation feedback, and both English and Norwegian Bokmål copy.
|
||||
- Improved the Job Workspace overview card grid so compact screens use one readable column, tablets use two, and wide screens use five without leaving a cramped orphan card.
|
||||
- Moved the remaining shared shell navigation labels into the global locale catalogue so sidebar and mobile-navigation controls announce themselves in the selected language.
|
||||
- Localized the application checklist from stable system keys rather than persisted English text. Categories, generated tasks, descriptions, progress, errors, tooltips, and accessible reorder/delete names now switch between English and Bokmål while user-authored task text remains untouched.
|
||||
|
||||
### In progress
|
||||
|
||||
- P0 deployment and production health revalidation. Commit `4d4af47` is pushed and Gitea Actions run 696 exists, but the run remained queued while the public health endpoint returned 502 during the first check.
|
||||
- Shared UI foundation rollout to remaining screens and standardized loading/empty/error copy.
|
||||
- Shared loading/empty/error-state consolidation is the next active UI package.
|
||||
|
||||
### Remaining
|
||||
|
||||
- Finish production deployment/health verification and revalidate the production-only blockers.
|
||||
- Continue shared hierarchy/responsive work across Career Workspace, Job Workspace, Profile, Admin, Jobs tables, Kanban, and CV Builder.
|
||||
- Add a real frontend lint dependency/configuration once package-index access is explicitly authorized; the current lockfile contains no ESLint packages.
|
||||
- Continue maintainability packages, accessibility automation, mobile regression coverage, and the prioritized product-value roadmap after the foundations are stable.
|
||||
|
||||
### Blocked
|
||||
|
||||
- ESLint installation is blocked by the repository rule requiring explicit permission before package-index/internet access.
|
||||
- Production rollout remains dependent on an online Gitea runner matching `ubuntu-latest` and the deployment host becoming healthy.
|
||||
- Existing external/provider/retention decisions remain listed in `BLOCKERS.md` and are not silently reclassified here.
|
||||
|
||||
### Discovered during implementation
|
||||
|
||||
- The prior E2E command relied on both `dotnet` and nested `npm` being discoverable through ambient PATH. This reproduced the audit's tooling failure on the current workstation.
|
||||
- The 375px Settings regression test still expected desktop tabs. It now verifies the responsive section selector instead.
|
||||
- The previous Dashboard rendered its time-in-stage empty state using the unrelated “No tags yet” copy. Empty analytical panels are now suppressed instead of presenting mismatched messages.
|
||||
- The Application Analysis match previously ignored both `JobApplication.DescriptionLanguage`/`TranslatedDescription` and the linked CV's language setting. This could under-score an English CV against a Norwegian advert even when import had already stored a usable English translation.
|
||||
- The bundled portable Node runtime starts Next 16 successfully, but Turbopack repeatedly panicked while emitting `/page`; the webpack development path serves the same route correctly and is now the documented/scripted default.
|
||||
|
||||
### Verification
|
||||
|
||||
- Focused frontend: 2 suites, 6 tests passed.
|
||||
- Full frontend: 62 suites, 256 tests passed.
|
||||
- Next production build and TypeScript: passed.
|
||||
- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors.
|
||||
- Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate.
|
||||
- Focused backend match/intelligence verification: 34/34 passed.
|
||||
- Focused frontend application-intelligence verification: 11/11 passed.
|
||||
- Career/Profile focused verification: 2 suites, 19/19 passed, including the final navigation/state-preservation regression; final full-suite/E2E gates remain pending.
|
||||
- Job Workspace focused verification: 2 suites, 9/9 passed; TypeScript passed.
|
||||
- Checklist localization verification: 7/7 passed, including a Bokmål regression that preserves user-authored content.
|
||||
- Full backend: 712/712 tests passed on .NET 9.
|
||||
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
|
||||
- Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate.
|
||||
- Manual desktop browser review: webpack development server rendered the new Career navigation and Overview correctly in dark mode; API-dependent profile status remained unavailable because the backend was not running for that isolated UI review.
|
||||
|
||||
- **Overall programme status:** Active but externally blocked. Eight packages are locally verified and twenty-five are implemented with verification incomplete. The prioritized admin-only version indicator, every immediate repository/browser item, SEC-009, the PROD-001 read-only inventory, and the PROD-003 safe benchmark harness are complete on the release branch.
|
||||
- **Current work package:** None. Every remaining package now requires a user/operator decision, authorized production mutation/restore/provider action, or explicit package-index access.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,282 +280,17 @@ 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;
|
||||
|
||||
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" }}>
|
||||
<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>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
'& .MuiButton-root': {
|
||||
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||
{t("dashboardMonthsShort", { count: m })}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
||||
{t("dashboardCustomize")}
|
||||
</Button>
|
||||
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
||||
{[
|
||||
["cards", t("dashboardSummaryCards")],
|
||||
["activity", t("dashboardActivityChart")],
|
||||
["funnel", t("dashboardConversionFunnel")],
|
||||
["companies", t("dashboardTopCompanies")],
|
||||
["skills", t("dashboardSkillsInsights")],
|
||||
].map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
|
||||
<Checkbox checked={prefs[key as keyof Prefs]} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ViewStateNotice
|
||||
loading={summaryResource.loading}
|
||||
error={summaryResource.error}
|
||||
title="Unable to load dashboard summary"
|
||||
description="The dashboard summary is unavailable right now."
|
||||
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."
|
||||
onRetry={trendsResource.reload}
|
||||
compact
|
||||
/>
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && 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
|
||||
key={card.label}
|
||||
sx={{
|
||||
transition: "box-shadow .2s, transform .2s",
|
||||
"&:hover": { boxShadow: 6, transform: "translateY(-2px)" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
|
||||
{card.trend || card.caption ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
|
||||
{card.trend ? (
|
||||
<>
|
||||
{card.trend.tone === "success" ? (
|
||||
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
|
||||
) : (
|
||||
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
|
||||
{card.trend.text}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
))}
|
||||
</Box>
|
||||
) : 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 ? (
|
||||
const priorityPanel = !summaryResource.loading && !summaryResource.error && hasJobs ? (
|
||||
<SectionCard>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box>
|
||||
<Typography variant="h6">{t("dashboardApplicationActivity")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
|
||||
</Stack>
|
||||
</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}`}>
|
||||
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1="0"
|
||||
x2={trendsView.chartWidth}
|
||||
y1={Math.round(trendsView.chartHeight * tick)}
|
||||
y2={Math.round(trendsView.chartHeight * tick)}
|
||||
stroke={alpha(theme.palette.text.primary, 0.08)}
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
))}
|
||||
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
|
||||
{analytics.map((point) => (
|
||||
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
|
||||
{point.month.slice(5)}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error ? (
|
||||
<SectionCard>
|
||||
<Typography variant="h6">{t("dashboardTimeInStageTitle")}</Typography>
|
||||
{overview?.timeInStage?.length ? (
|
||||
<Stack spacing={1.25} sx={{ mt: 1.5 }}>
|
||||
{overview.timeInStage.map((item, index) => (
|
||||
<Box key={item.stage}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.06),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 999,
|
||||
backgroundColor: tagColors[index % tagColors.length],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{t("dashboardNoTagsYet")}</Typography>
|
||||
)}
|
||||
|
||||
{tags.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<Chip
|
||||
key={tag.tag}
|
||||
size="small"
|
||||
label={`${tag.tag} · ${tag.count}`}
|
||||
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 2.25 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Stack spacing={1.2} sx={{ mt: 1 }}>
|
||||
{(overview?.funnel ?? []).map((item) => {
|
||||
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={width}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
|
||||
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionCard>
|
||||
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
|
||||
{overview!.salaryInsights!.map((item) => {
|
||||
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
|
||||
return (
|
||||
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</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>
|
||||
<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>
|
||||
) : (
|
||||
@@ -607,9 +342,270 @@ export default function DashboardView() {
|
||||
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
|
||||
|
||||
{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
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
'& .MuiButton-root': {
|
||||
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||
{t("dashboardMonthsShort", { count: m })}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
||||
{t("dashboardCustomize")}
|
||||
</Button>
|
||||
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
||||
{[
|
||||
["cards", t("dashboardSummaryCards")],
|
||||
["activity", t("dashboardActivityChart")],
|
||||
["funnel", t("dashboardConversionFunnel")],
|
||||
["companies", t("dashboardTopCompanies")],
|
||||
["skills", t("dashboardSkillsInsights")],
|
||||
].map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
|
||||
<Checkbox checked={prefs[key as keyof Prefs]} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box> : null}
|
||||
|
||||
<ViewStateNotice
|
||||
loading={summaryResource.loading}
|
||||
error={summaryResource.error}
|
||||
title={t("dashboardSummaryUnavailableTitle")}
|
||||
description={t("dashboardSummaryUnavailableBody")}
|
||||
onRetry={summaryResource.reload}
|
||||
/>
|
||||
<ViewStateNotice
|
||||
loading={trendsResource.loading}
|
||||
error={trendsResource.error}
|
||||
title={t("dashboardTrendsUnavailableTitle")}
|
||||
description={t("dashboardTrendsUnavailableBody")}
|
||||
onRetry={trendsResource.reload}
|
||||
compact
|
||||
/>
|
||||
|
||||
{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
|
||||
key={card.label}
|
||||
sx={{
|
||||
transition: "box-shadow .2s, transform .2s",
|
||||
"&:hover": { boxShadow: 6, transform: "translateY(-2px)" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{card.label}</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.5 }}>{card.value}</Typography>
|
||||
{card.trend || card.caption ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mt: 0.75 }}>
|
||||
{card.trend ? (
|
||||
<>
|
||||
{card.trend.tone === "success" ? (
|
||||
<ArrowUpwardIcon sx={{ fontSize: 14 }} color="success" />
|
||||
) : (
|
||||
<ArrowDownwardIcon sx={{ fontSize: 14 }} color="error" />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: card.trend.tone === "success" ? "success.main" : "error.main" }}>
|
||||
{card.trend.text}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{card.caption}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && prefs.companies ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}>
|
||||
{!summaryResource.loading && !summaryResource.error && hasActivityData && prefs.activity ? (
|
||||
<SectionCard>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box>
|
||||
<Typography variant="h6">{t("dashboardApplicationActivity")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.primary.main }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardAppliedCount", { count: trendsView.totalApplied })}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: 0.75, backgroundColor: theme.palette.success.main }} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardResponsesCount", { count: trendsView.totalResponses })}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<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}
|
||||
x1="0"
|
||||
x2={trendsView.chartWidth}
|
||||
y1={Math.round(trendsView.chartHeight * tick)}
|
||||
y2={Math.round(trendsView.chartHeight * tick)}
|
||||
stroke={alpha(theme.palette.text.primary, 0.08)}
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
))}
|
||||
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={theme.palette.success.main} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={theme.palette.primary.main} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
|
||||
{analytics.map((point) => (
|
||||
<Typography key={point.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center", color: "text.secondary" }}>
|
||||
{point.month.slice(5)}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{!summaryResource.loading && !summaryResource.error && (hasStageData || funnelItems.length > 0 || summaryView.topSource) ? (
|
||||
<SectionCard>
|
||||
{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}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={clamp((item.medianDays / timeInStageMax) * 100, 4, 100)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.06),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 999,
|
||||
backgroundColor: tagColors[index % tagColors.length],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>) : null}
|
||||
|
||||
{tags.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<Chip
|
||||
key={tag.tag}
|
||||
size="small"
|
||||
label={`${tag.tag} · ${tag.count}`}
|
||||
sx={{ backgroundColor: alpha(theme.palette.primary.main, 0.1), color: "primary.main", fontWeight: 700 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{funnelItems.length > 0 ? <>
|
||||
<Typography variant="h6" sx={{ mt: hasStageData || hasSkills ? 2.25 : 0 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Stack spacing={1.2} sx={{ mt: 1 }}>
|
||||
{funnelItems.map((item) => {
|
||||
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={width}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${alpha(theme.palette.success.main, 0.85)})`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</> : null}
|
||||
|
||||
{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> : null}
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
|
||||
{(overview?.salaryInsights?.length ?? 0) > 0 ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionCard>
|
||||
<Typography variant="h6">{t("dashboardSalaryInsights")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 1.5 }}>{t("dashboardSalaryInsightsBody")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 1.5 }}>
|
||||
{overview!.salaryInsights!.map((item) => {
|
||||
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
|
||||
return (
|
||||
<Box key={item.currency + item.period} sx={{ p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="caption" color="text.secondary">{t("dashboardSalaryGroup", { currency: item.currency, period: item.period, count: item.count })}</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 0.5 }}>{money.format(item.averageMidpoint)}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("dashboardSalaryRange", { minimum: money.format(item.minimum), maximum: money.format(item.maximum) })}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<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,12 +624,9 @@ 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}>
|
||||
{tags.slice(0, 8).map((tag, index) => {
|
||||
const max = Math.max(...tags.map((item) => item.count), 1);
|
||||
@@ -651,7 +644,6 @@ export default function DashboardView() {
|
||||
);
|
||||
})}
|
||||
</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) }}>
|
||||
<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 }} />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user