feat(i18n): persist Bokmal preference globally
This commit is contained in:
@@ -426,6 +426,35 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
Assert.Equal("Ada L.", user.DisplayName);
|
Assert.Equal("Ada L.", user.DisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Language_preference_normalizes_and_persists_norwegian_bokmal()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1", UiLanguage = "en" };
|
||||||
|
var users = CreateUserManager();
|
||||||
|
users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
|
||||||
|
|
||||||
|
var result = await controller.UpdateLanguagePreference(new AuthController.UpdateLanguagePreferenceRequest("nb"));
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
Assert.Equal("nb-NO", user.UiLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Language_preference_rejects_an_unsupported_locale()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser { Id = "user-1", UiLanguage = "en" };
|
||||||
|
var users = CreateUserManager();
|
||||||
|
users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
|
||||||
|
|
||||||
|
var result = await controller.UpdateLanguagePreference(new AuthController.UpdateLanguagePreferenceRequest("sv-SE"));
|
||||||
|
|
||||||
|
Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
users.Verify(x => x.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Request_email_change_keeps_current_email_and_sends_confirmation_to_new_address()
|
public async Task Request_email_change_keeps_current_email_and_sends_confirmation_to_new_address()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public sealed class MigrationChainTests
|
|||||||
SELECT COUNT(*) FROM pragma_table_info('AspNetUsers')
|
SELECT COUNT(*) FROM pragma_table_info('AspNetUsers')
|
||||||
WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true');
|
WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true');
|
||||||
"""));
|
"""));
|
||||||
|
Assert.Equal(1, await ScalarAsync<long>(connection, """
|
||||||
|
SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') WHERE name = 'UiLanguage';
|
||||||
|
"""));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -93,6 +96,7 @@ public sealed class MigrationChainTests
|
|||||||
Assert.Contains("CONSTRAINT `FK_AccountDeletionFiles_Request`", script, StringComparison.Ordinal);
|
Assert.Contains("CONSTRAINT `FK_AccountDeletionFiles_Request`", script, StringComparison.Ordinal);
|
||||||
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUsers`", script, StringComparison.Ordinal);
|
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUsers`", script, StringComparison.Ordinal);
|
||||||
Assert.Contains("CREATE TABLE IF NOT EXISTS `AiInteractions`", script, StringComparison.Ordinal);
|
Assert.Contains("CREATE TABLE IF NOT EXISTS `AiInteractions`", script, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
|
||||||
Assert.All(
|
Assert.All(
|
||||||
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
|
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
|
||||||
identifier => Assert.True(identifier.Length <= 64, $"MariaDB constraint identifier exceeds 64 characters: {identifier}"));
|
identifier => Assert.True(identifier.Length <= 64, $"MariaDB constraint identifier exceeds 64 characters: {identifier}"));
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
public string AppVersion { get; init; } = "unknown";
|
public string AppVersion { get; init; } = "unknown";
|
||||||
public string? AppCommitSha { get; init; }
|
public string? AppCommitSha { get; init; }
|
||||||
|
public string UiLanguage { get; init; } = "en";
|
||||||
}
|
}
|
||||||
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
|
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
|
||||||
private const int MaxAvatarBytes = 1_000_000;
|
private const int MaxAvatarBytes = 1_000_000;
|
||||||
@@ -539,6 +540,21 @@ public sealed class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateLanguagePreferenceRequest(string? Language);
|
||||||
|
|
||||||
|
[HttpPut("preferences/language")]
|
||||||
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
|
public async Task<IActionResult> UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request)
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
var language = NormalizeUiLanguage(request?.Language);
|
||||||
|
if (language is null) return BadRequest("Supported languages are en and nb-NO.");
|
||||||
|
user.UiLanguage = language;
|
||||||
|
var result = await _users.UpdateAsync(user);
|
||||||
|
return result.Succeeded ? NoContent() : BadRequest(string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
|
public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
|
||||||
public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
|
public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
|
||||||
public sealed record CancelEmailChangeRequest(string CurrentPassword);
|
public sealed record CancelEmailChangeRequest(string CurrentPassword);
|
||||||
@@ -1200,7 +1216,20 @@ public sealed class AuthController : ControllerBase
|
|||||||
MicrosoftLink: new MicrosoftLinkDto(
|
MicrosoftLink: new MicrosoftLinkDto(
|
||||||
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
|
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
|
||||||
Email: user.MicrosoftEmail,
|
Email: user.MicrosoftEmail,
|
||||||
LinkedAt: user.MicrosoftLinkedAt));
|
LinkedAt: user.MicrosoftLinkedAt))
|
||||||
|
{
|
||||||
|
UiLanguage = NormalizeUiLanguage(user.UiLanguage) ?? "en",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeUiLanguage(string? language)
|
||||||
|
{
|
||||||
|
var value = language?.Trim();
|
||||||
|
if (string.Equals(value, "en", StringComparison.OrdinalIgnoreCase)) return "en";
|
||||||
|
if (string.Equals(value, "nb", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value, "nb-NO", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value, "no", StringComparison.OrdinalIgnoreCase)) return "nb-NO";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MeResult WithBuildMetadata(MeResult result, bool include)
|
private MeResult WithBuildMetadata(MeResult result, bool include)
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ namespace JobTrackerApi.Data
|
|||||||
.Property(x => x.EmailFollowUpRemindersEnabled)
|
.Property(x => x.EmailFollowUpRemindersEnabled)
|
||||||
.HasDefaultValue(true);
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ApplicationUser>()
|
||||||
|
.Property(x => x.UiLanguage)
|
||||||
|
.HasMaxLength(16);
|
||||||
|
|
||||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
|
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
|
||||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
|
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Migrations;
|
||||||
|
|
||||||
|
[DbContext(typeof(JobTrackerContext))]
|
||||||
|
[Migration("20260828090000_AddUiLanguagePreference")]
|
||||||
|
public partial class AddUiLanguagePreference : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UiLanguage",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "varchar(16)" : "TEXT",
|
||||||
|
maxLength: 16,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "UiLanguage", table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -481,6 +481,10 @@ namespace JobTrackerApi.Migrations
|
|||||||
b.Property<string>("StripeSubscriptionStatus")
|
b.Property<string>("StripeSubscriptionStatus")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UiLanguage")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
|
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public sealed class ApplicationUser : IdentityUser
|
|||||||
public bool AiEnabled { get; set; } = true;
|
public bool AiEnabled { get; set; } = true;
|
||||||
public bool ExternalAiProcessingAllowed { get; set; }
|
public bool ExternalAiProcessingAllowed { get; set; }
|
||||||
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
|
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
|
||||||
|
// BCP 47 application UI preference. Null/legacy values safely fall back to English.
|
||||||
|
public string? UiLanguage { get; set; }
|
||||||
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
|
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
|
||||||
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
|
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,6 +533,7 @@ public static class StartupInitializationExtensions
|
|||||||
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
|
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
|
||||||
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
|
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
|
||||||
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
|
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
|
||||||
|
EnsureColumn(conn, "AspNetUsers", "UiLanguage", "ALTER TABLE AspNetUsers ADD COLUMN UiLanguage TEXT NULL;");
|
||||||
|
|
||||||
static void EnsureUserRuleSettingsTable(DbConnection c)
|
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||||||
{
|
{
|
||||||
@@ -1435,6 +1436,7 @@ public static class StartupInitializationExtensions
|
|||||||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
|
||||||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
|
||||||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
|
||||||
|
EnsureMySqlColumn(conn, "AspNetUsers", "UiLanguage", "ALTER TABLE `AspNetUsers` ADD COLUMN `UiLanguage` varchar(16) NULL;");
|
||||||
|
|
||||||
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
|
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
|
||||||
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
|
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type MeResponse = {
|
|||||||
entitlements?: { ai?: boolean; proThemes?: boolean };
|
entitlements?: { ai?: boolean; proThemes?: boolean };
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
appCommitSha?: string;
|
appCommitSha?: string;
|
||||||
|
uiLanguage?: "en" | "nb-NO";
|
||||||
};
|
};
|
||||||
|
|
||||||
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||||
@@ -154,7 +155,7 @@ function LegacyApplicationRedirect() {
|
|||||||
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
|
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useI18n();
|
const { t, hydrateLanguage } = useI18n();
|
||||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||||
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
@@ -198,6 +199,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setMe(r.data);
|
setMe(r.data);
|
||||||
|
hydrateLanguage(r.data?.uiLanguage);
|
||||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||||
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
||||||
})
|
})
|
||||||
@@ -213,7 +215,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [hydrateLanguage]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = () => {
|
const load = () => {
|
||||||
api.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setReminderCount(0));
|
api.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setReminderCount(0));
|
||||||
@@ -240,6 +242,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
api.get<MeResponse>("/auth/me")
|
api.get<MeResponse>("/auth/me")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setMe(r.data);
|
setMe(r.data);
|
||||||
|
hydrateLanguage(r.data?.uiLanguage);
|
||||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||||
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
||||||
})
|
})
|
||||||
@@ -253,7 +256,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
|
|
||||||
window.addEventListener("auth-changed", onAuthChanged);
|
window.addEventListener("auth-changed", onAuthChanged);
|
||||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||||
}, []);
|
}, [hydrateLanguage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
|
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
|
||||||
} from "./components/ApplicationAssets";
|
} from "./components/ApplicationAssets";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
|
|
||||||
|
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider>{ui}</I18nProvider>);
|
||||||
|
|
||||||
jest.mock("./api", () => ({
|
jest.mock("./api", () => ({
|
||||||
api: {
|
api: {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||||
} from "./components/ApplicationIntelligence";
|
} from "./components/ApplicationIntelligence";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
|
|
||||||
|
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider>{ui}</I18nProvider>);
|
||||||
|
|
||||||
jest.mock("./api", () => ({
|
jest.mock("./api", () => ({
|
||||||
api: {
|
api: {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function normalizeLanguage(value?: string | null) {
|
|||||||
const raw = (value || "").trim().toLowerCase();
|
const raw = (value || "").trim().toLowerCase();
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
if (["en", "eng", "english"].includes(raw)) return "en";
|
if (["en", "eng", "english"].includes(raw)) return "en";
|
||||||
if (["no", "nb", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "no";
|
if (["no", "nb", "nb-no", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "nb";
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { cvBuilderApi } from "../cvBuilder";
|
import { cvBuilderApi } from "../cvBuilder";
|
||||||
import { aiWorkspaceApi } from "../aiWorkspace";
|
import { aiWorkspaceApi } from "../aiWorkspace";
|
||||||
import { useAccountPlan } from "../accountPlan";
|
import { useAccountPlan } from "../accountPlan";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
// Phase 5.4 — Application Assets sections for the workspace.
|
// Phase 5.4 — Application Assets sections for the workspace.
|
||||||
//
|
//
|
||||||
@@ -417,10 +418,11 @@ const COVER_LETTER_ACTIONS = [
|
|||||||
|
|
||||||
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
|
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
|
||||||
const { canUseAi } = useAccountPlan();
|
const { canUseAi } = useAccountPlan();
|
||||||
|
const { language: uiLanguage, t } = useI18n();
|
||||||
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
|
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
|
||||||
const [action, setAction] = useState("generate");
|
const [action, setAction] = useState("generate");
|
||||||
const [mode, setMode] = useState("professional");
|
const [mode, setMode] = useState("professional");
|
||||||
const [language, setLanguage] = useState<"en" | "nb-NO">("en");
|
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
|
||||||
const [instructions, setInstructions] = useState("");
|
const [instructions, setInstructions] = useState("");
|
||||||
const [suggestion, setSuggestion] = useState("");
|
const [suggestion, setSuggestion] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -449,17 +451,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
|||||||
const hasCv = !!cv?.attachedVariantId;
|
const hasCv = !!cv?.attachedVariantId;
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
title="AI writing assistant"
|
title={t("coverAiTitle")}
|
||||||
subtitle="Uses this job and its linked CV. Suggestions never overwrite your document."
|
subtitle={t("coverAiSubtitle")}
|
||||||
loading={loadingCv}
|
loading={loadingCv}
|
||||||
error={null}
|
error={null}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{!hasCv ? (
|
{!hasCv ? (
|
||||||
<Alert severity="info">Select a CV before generating a tailored cover letter.</Alert>
|
<Alert severity="info">{t("coverAiSelectCv")}</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
|
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
Using <strong>{cv?.attachedVariantName}</strong> and this application's full job advert and analysis.
|
{t("coverAiUsing")} <strong>{cv?.attachedVariantName}</strong>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||||
@@ -478,15 +480,15 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||||
<InputLabel>Document language</InputLabel>
|
<InputLabel>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||||
<Select label="Document language" value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
<Select label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||||
<MenuItem value="en">English</MenuItem>
|
<MenuItem value="en">English</MenuItem>
|
||||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Stack>
|
</Stack>
|
||||||
<TextField
|
<TextField
|
||||||
label="Additional instructions"
|
label={t("coverAiAdditionalInstructions")}
|
||||||
placeholder="For example: Focus on my .NET experience and keep it concise."
|
placeholder="For example: Focus on my .NET experience and keep it concise."
|
||||||
value={instructions}
|
value={instructions}
|
||||||
onChange={(event) => setInstructions(event.target.value)}
|
onChange={(event) => setInstructions(event.target.value)}
|
||||||
@@ -508,17 +510,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
|
|||||||
{suggestion && (
|
{suggestion && (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 1.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
|
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
|
||||||
<Typography variant="overline" color="text.secondary">Current</Typography>
|
<Typography variant="overline" color="text.secondary">{t("coverAiCurrent")}</Typography>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||||
{currentText || "No current draft"}
|
{currentText || "No current draft"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
|
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
|
||||||
<Typography variant="overline" color="primary">Suggestion</Typography>
|
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
|
||||||
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||||
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>Apply to editor</Button>
|
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>{t("coverAiApply")}</Button>
|
||||||
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
|
<Button size="small" onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getApiErrorMessage } from "../api";
|
|||||||
import {
|
import {
|
||||||
CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi,
|
CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi,
|
||||||
} from "../applicationWorkspace";
|
} from "../applicationWorkspace";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
// Phase 5.3 — Application Intelligence sections for the workspace.
|
// Phase 5.3 — Application Intelligence sections for the workspace.
|
||||||
//
|
//
|
||||||
@@ -255,6 +256,7 @@ export function ApplicationAnalysis({ jobId }: { jobId: number }) {
|
|||||||
// ---------- Match ----------
|
// ---------- Match ----------
|
||||||
|
|
||||||
export function ApplicationMatch({ jobId }: { jobId: number }) {
|
export function ApplicationMatch({ jobId }: { jobId: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data, error, loading } = useIntelligence<CareerMatch>(
|
const { data, error, loading } = useIntelligence<CareerMatch>(
|
||||||
() => applicationIntelligenceApi.match(jobId),
|
() => applicationIntelligenceApi.match(jobId),
|
||||||
[jobId],
|
[jobId],
|
||||||
@@ -262,17 +264,17 @@ export function ApplicationMatch({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionShell
|
<SectionShell
|
||||||
title="CV Match"
|
title={t("workspaceCvMatch")}
|
||||||
subtitle={data?.selectedCvName
|
subtitle={data?.selectedCvName
|
||||||
? `${data.selectedCvName} compared with this job advert.`
|
? `${t("workspaceComparingCv")} ${data.selectedCvName}`
|
||||||
: "Choose the CV intended for this application before comparing it with the advert."}
|
: t("workspaceSelectCvMatch")}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{data && !data.hasSelectedCv ? (
|
{data && !data.hasSelectedCv ? (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
Select a CV on the CV tab first. The application will only analyse the document you explicitly link.
|
{t("workspaceSelectCvFirst")}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : data && !data.hasCareerProfile ? (
|
) : data && !data.hasCareerProfile ? (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
|
|||||||
@@ -138,10 +138,10 @@ export default function SettingsView({
|
|||||||
labelId="language-label"
|
labelId="language-label"
|
||||||
value={language}
|
value={language}
|
||||||
label={t("settingsPreferredLanguage")}
|
label={t("settingsPreferredLanguage")}
|
||||||
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
|
onChange={(e) => setLanguage(e.target.value as "en" | "nb")}
|
||||||
>
|
>
|
||||||
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
|
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
|
||||||
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
<MenuItem value="nb">{t("settingsNorwegian")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { api } from "./api";
|
||||||
|
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const { language, setLanguage, hydrateLanguage, t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<output aria-label="language">{language}</output>
|
||||||
|
<output aria-label="settings-label">{t("settings")}</output>
|
||||||
|
<button onClick={() => setLanguage("en")}>English</button>
|
||||||
|
<button onClick={() => setLanguage("nb")}>Norsk</button>
|
||||||
|
<button onClick={() => hydrateLanguage("nb-NO")}>Restore server preference</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrates the legacy Norwegian code and persists Bokmål using nb-NO", async () => {
|
||||||
|
window.localStorage.setItem("uiLanguage", "no");
|
||||||
|
render(<I18nProvider><Probe /></I18nProvider>);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("language")).toHaveTextContent("nb");
|
||||||
|
expect(screen.getByLabelText("settings-label")).toHaveTextContent("Innstillinger");
|
||||||
|
expect(document.documentElement.lang).toBe("nb-NO");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "English" }));
|
||||||
|
expect(screen.getByLabelText("settings-label")).toHaveTextContent("Settings");
|
||||||
|
await waitFor(() => expect(api.put).toHaveBeenCalledWith("/auth/preferences/language", { language: "en" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrates an authenticated server preference without writing it back", () => {
|
||||||
|
render(<I18nProvider><Probe /></I18nProvider>);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Restore server preference/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("language")).toHaveTextContent("nb");
|
||||||
|
expect(window.localStorage.getItem("uiLanguage")).toBe("nb");
|
||||||
|
expect(api.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { createContext, useContext, useState } from "react";
|
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { translations, TranslationKey, UiLanguage } from "./translations";
|
import { translations, TranslationKey, UiLanguage } from "./translations";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
type TranslationParams = Record<string, string | number>;
|
type TranslationParams = Record<string, string | number>;
|
||||||
|
|
||||||
type Ctx = {
|
type Ctx = {
|
||||||
language: UiLanguage;
|
language: UiLanguage;
|
||||||
setLanguage: (l: UiLanguage) => void;
|
setLanguage: (l: UiLanguage) => void;
|
||||||
|
hydrateLanguage: (language?: string | null) => void;
|
||||||
t: (key: TranslationKey, params?: TranslationParams) => string;
|
t: (key: TranslationKey, params?: TranslationParams) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,17 +21,38 @@ function interpolate(template: string, params?: TranslationParams) {
|
|||||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [language, setLanguageState] = useState<UiLanguage>(() => {
|
const [language, setLanguageState] = useState<UiLanguage>(() => {
|
||||||
const raw = window.localStorage.getItem("uiLanguage");
|
const raw = window.localStorage.getItem("uiLanguage");
|
||||||
return raw === "no" ? "no" : "en";
|
return raw === "nb" || raw === "nb-NO" || raw === "no" ? "nb" : "en";
|
||||||
});
|
});
|
||||||
|
|
||||||
const setLanguage = (l: UiLanguage) => {
|
const applyLanguage = useCallback((l: UiLanguage) => {
|
||||||
setLanguageState(l);
|
setLanguageState(l);
|
||||||
window.localStorage.setItem("uiLanguage", l);
|
window.localStorage.setItem("uiLanguage", l);
|
||||||
|
document.documentElement.lang = l === "nb" ? "nb-NO" : "en";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLanguage = (l: UiLanguage) => {
|
||||||
|
applyLanguage(l);
|
||||||
|
void api.put("/auth/preferences/language", { language: l === "nb" ? "nb-NO" : "en" }).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hydrateLanguage = useCallback((value?: string | null) => {
|
||||||
|
if (!value) return;
|
||||||
|
applyLanguage(value === "nb" || value === "nb-NO" || value === "no" ? "nb" : "en");
|
||||||
|
}, [applyLanguage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language === "nb" ? "nb-NO" : "en";
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== "uiLanguage") return;
|
||||||
|
applyLanguage(event.newValue === "nb" || event.newValue === "nb-NO" || event.newValue === "no" ? "nb" : "en");
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
|
}, [applyLanguage, language]);
|
||||||
|
|
||||||
const t = (key: TranslationKey, params?: TranslationParams) => interpolate(translations[language][key] ?? translations.en[key], params);
|
const t = (key: TranslationKey, params?: TranslationParams) => interpolate(translations[language][key] ?? translations.en[key], params);
|
||||||
|
|
||||||
return <I18nContext.Provider value={{ language, setLanguage, t }}>{children}</I18nContext.Provider>;
|
return <I18nContext.Provider value={{ language, setLanguage, hydrateLanguage, t }}>{children}</I18nContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useI18n() {
|
export function useI18n() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type UiLanguage = "en" | "no";
|
export type UiLanguage = "en" | "nb";
|
||||||
|
|
||||||
export const translations = {
|
export const translations = {
|
||||||
en: {
|
en: {
|
||||||
@@ -1170,8 +1170,50 @@ export const translations = {
|
|||||||
rulesSaving: "Saving...",
|
rulesSaving: "Saving...",
|
||||||
rulesSave: "Save Rules",
|
rulesSave: "Save Rules",
|
||||||
rulesSaveFailed: "Failed to save rules.",
|
rulesSaveFailed: "Failed to save rules.",
|
||||||
|
workspace: "Workspace",
|
||||||
|
workspaceBack: "Back to applications",
|
||||||
|
workspaceOpenFull: "Open full-page workspace",
|
||||||
|
workspaceSections: "Workspace sections",
|
||||||
|
workspaceOverview: "Overview",
|
||||||
|
workspaceAnalysis: "Analysis",
|
||||||
|
workspaceCv: "CV",
|
||||||
|
workspaceCoverLetter: "Cover Letter",
|
||||||
|
workspaceInterviewPrep: "Interview Prep",
|
||||||
|
workspaceInvalidLink: "This application link is invalid.",
|
||||||
|
workspaceLoadFailed: "Could not open this application.",
|
||||||
|
workspaceUnsavedTitle: "Unsaved application changes",
|
||||||
|
workspaceUnsavedMessage: "Leaving this section will discard changes that have not been saved.",
|
||||||
|
workspaceDiscardLeave: "Discard and leave",
|
||||||
|
workspaceKeepEditing: "Keep editing",
|
||||||
|
workspaceEditApplication: "Edit application",
|
||||||
|
workspaceOpenAdvert: "Open original advert",
|
||||||
|
workspaceProgress: "Application progress",
|
||||||
|
workspaceNextAction: "Next recommended action",
|
||||||
|
workspaceNothingOutstanding: "Nothing outstanding — this application is fully prepared.",
|
||||||
|
workspaceRecentActivity: "Recent activity",
|
||||||
|
workspaceRefresh: "Refresh",
|
||||||
|
workspaceNoActivity: "No activity recorded yet.",
|
||||||
|
workspaceJobDetails: "Job details",
|
||||||
|
workspaceChecklist: "Next actions and checklist",
|
||||||
|
workspaceActivityHistory: "Activity history",
|
||||||
|
workspaceDocuments: "Documents",
|
||||||
|
workspaceCommunication: "Communication",
|
||||||
|
workspaceCvMatch: "CV Match",
|
||||||
|
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.",
|
||||||
|
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.",
|
||||||
|
coverAiUsing: "Using {name} and this application's full job advert and analysis.",
|
||||||
|
coverAiDocumentLanguage: "Document language",
|
||||||
|
coverAiAdditionalInstructions: "Additional instructions",
|
||||||
|
coverAiCurrent: "Current",
|
||||||
|
coverAiSuggestion: "Suggestion",
|
||||||
|
coverAiApply: "Apply to editor",
|
||||||
|
coverAiReject: "Reject",
|
||||||
},
|
},
|
||||||
no: {
|
nb: {
|
||||||
appTitle: "Jobbjakt",
|
appTitle: "Jobbjakt",
|
||||||
appTagline: "Hold oversikt over jobbsøkingen",
|
appTagline: "Hold oversikt over jobbsøkingen",
|
||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
@@ -2340,6 +2382,48 @@ export const translations = {
|
|||||||
rulesSaving: "Lagrer...",
|
rulesSaving: "Lagrer...",
|
||||||
rulesSave: "Lagre regler",
|
rulesSave: "Lagre regler",
|
||||||
rulesSaveFailed: "Kunne ikke lagre regler.",
|
rulesSaveFailed: "Kunne ikke lagre regler.",
|
||||||
|
workspace: "Arbeidsområde",
|
||||||
|
workspaceBack: "Tilbake til søknader",
|
||||||
|
workspaceOpenFull: "Åpne arbeidsområdet på helside",
|
||||||
|
workspaceSections: "Deler av arbeidsområdet",
|
||||||
|
workspaceOverview: "Oversikt",
|
||||||
|
workspaceAnalysis: "Analyse",
|
||||||
|
workspaceCv: "CV",
|
||||||
|
workspaceCoverLetter: "Søknadsbrev",
|
||||||
|
workspaceInterviewPrep: "Intervjuforberedelse",
|
||||||
|
workspaceInvalidLink: "Denne søknadslenken er ugyldig.",
|
||||||
|
workspaceLoadFailed: "Kunne ikke åpne denne søknaden.",
|
||||||
|
workspaceUnsavedTitle: "Ulagrede søknadsendringer",
|
||||||
|
workspaceUnsavedMessage: "Hvis du forlater denne delen, forkastes endringer som ikke er lagret.",
|
||||||
|
workspaceDiscardLeave: "Forkast og forlat",
|
||||||
|
workspaceKeepEditing: "Fortsett å redigere",
|
||||||
|
workspaceEditApplication: "Rediger søknad",
|
||||||
|
workspaceOpenAdvert: "Åpne den opprinnelige annonsen",
|
||||||
|
workspaceProgress: "Søknadsprogresjon",
|
||||||
|
workspaceNextAction: "Neste anbefalte handling",
|
||||||
|
workspaceNothingOutstanding: "Ingenting gjenstår — denne søknaden er ferdig forberedt.",
|
||||||
|
workspaceRecentActivity: "Nylig aktivitet",
|
||||||
|
workspaceRefresh: "Oppdater",
|
||||||
|
workspaceNoActivity: "Ingen aktivitet er registrert ennå.",
|
||||||
|
workspaceJobDetails: "Stillingsdetaljer",
|
||||||
|
workspaceChecklist: "Neste handlinger og sjekkliste",
|
||||||
|
workspaceActivityHistory: "Aktivitetshistorikk",
|
||||||
|
workspaceDocuments: "Dokumenter",
|
||||||
|
workspaceCommunication: "Kommunikasjon",
|
||||||
|
workspaceCvMatch: "CV-samsvar",
|
||||||
|
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.",
|
||||||
|
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.",
|
||||||
|
coverAiUsing: "Bruker {name} samt hele stillingsannonsen og analysen for denne søknaden.",
|
||||||
|
coverAiDocumentLanguage: "Dokumentspråk",
|
||||||
|
coverAiAdditionalInstructions: "Tilleggsinstruksjoner",
|
||||||
|
coverAiCurrent: "Nåværende",
|
||||||
|
coverAiSuggestion: "Forslag",
|
||||||
|
coverAiApply: "Bruk i redigeringsfeltet",
|
||||||
|
coverAiReject: "Avvis",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
Chip,
|
Chip,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -112,7 +114,7 @@ export default function AppShell({
|
|||||||
rightActions?: React.ReactNode;
|
rightActions?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { language, setLanguage, t } = useI18n();
|
||||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||||
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@@ -309,6 +311,7 @@ export default function AppShell({
|
|||||||
{buildBadge}
|
{buildBadge}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<StackLanguageToggle language={language} setLanguage={setLanguage} />
|
||||||
{user ? (
|
{user ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -388,6 +391,7 @@ export default function AppShell({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{buildBadge}
|
{buildBadge}
|
||||||
|
<StackLanguageToggle language={language} setLanguage={setLanguage} />
|
||||||
<IconButton
|
<IconButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -438,7 +442,6 @@ export default function AppShell({
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={userMenuAnchor}
|
anchorEl={userMenuAnchor}
|
||||||
open={userMenuOpen}
|
open={userMenuOpen}
|
||||||
@@ -552,3 +555,12 @@ export default function AppShell({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) {
|
||||||
|
return (
|
||||||
|
<ButtonGroup size="small" variant="outlined" aria-label="Application language" sx={{ flex: "0 0 auto", "& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 } }}>
|
||||||
|
<Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button>
|
||||||
|
<Button aria-label="Norsk" aria-pressed={language === "nb"} variant={language === "nb" ? "contained" : "outlined"} onClick={() => setLanguage("nb")}>NO</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function ApplicationWorkspace({
|
|||||||
const jobId = jobIdOverride ?? Number(id);
|
const jobId = jobIdOverride ?? Number(id);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const section = sectionOverride ?? workspaceSection(params.get("section"));
|
const section = sectionOverride ?? workspaceSection(params.get("section"));
|
||||||
@@ -96,10 +97,10 @@ export function ApplicationWorkspace({
|
|||||||
const blockedNavigation = blocker;
|
const blockedNavigation = blocker;
|
||||||
let active = true;
|
let active = true;
|
||||||
void confirm({
|
void confirm({
|
||||||
title: "Unsaved application changes",
|
title: t("workspaceUnsavedTitle"),
|
||||||
message: "Leaving this section will discard changes that have not been saved.",
|
message: t("workspaceUnsavedMessage"),
|
||||||
confirmLabel: "Discard and leave",
|
confirmLabel: t("workspaceDiscardLeave"),
|
||||||
cancelLabel: "Keep editing",
|
cancelLabel: t("workspaceKeepEditing"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}).then((approved) => {
|
}).then((approved) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -107,21 +108,21 @@ export function ApplicationWorkspace({
|
|||||||
else blockedNavigation.reset();
|
else blockedNavigation.reset();
|
||||||
});
|
});
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [blocker, confirm]);
|
}, [blocker, confirm, t]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isInteger(jobId) || jobId <= 0) {
|
if (!Number.isInteger(jobId) || jobId <= 0) {
|
||||||
setOverview(null);
|
setOverview(null);
|
||||||
setError("This application link is invalid.");
|
setError(t("workspaceInvalidLink"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setOverview(await applicationWorkspaceApi.overview(jobId));
|
setOverview(await applicationWorkspaceApi.overview(jobId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getApiErrorMessage(err, "Could not open this application."));
|
setError(getApiErrorMessage(err, t("workspaceLoadFailed")));
|
||||||
}
|
}
|
||||||
}, [jobId]);
|
}, [jobId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -144,7 +145,7 @@ export function ApplicationWorkspace({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
<Button startIcon={<ArrowBackIcon />} onClick={close}>Back to applications</Button>
|
<Button startIcon={<ArrowBackIcon />} onClick={close}>{t("workspaceBack")}</Button>
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
|
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -154,23 +155,23 @@ export function ApplicationWorkspace({
|
|||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
|
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
||||||
<Tooltip title="Back to applications">
|
<Tooltip title={t("workspaceBack")}>
|
||||||
<IconButton size="small" aria-label="Back to applications" onClick={close}>
|
<IconButton size="small" aria-label={t("workspaceBack")} onClick={close}>
|
||||||
<ArrowBackIcon fontSize="small" />
|
<ArrowBackIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
|
||||||
Workspace
|
{t("workspace")}
|
||||||
</Typography>
|
</Typography>
|
||||||
{fullPageHref ? (
|
{fullPageHref ? (
|
||||||
<Tooltip title="Open full-page workspace">
|
<Tooltip title={t("workspaceOpenFull")}>
|
||||||
<IconButton
|
<IconButton
|
||||||
component="a"
|
component="a"
|
||||||
href={fullPageHref}
|
href={fullPageHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Open full-page workspace"
|
aria-label={t("workspaceOpenFull")}
|
||||||
sx={{ ml: "auto" }}
|
sx={{ ml: "auto" }}
|
||||||
>
|
>
|
||||||
<OpenInNewIcon fontSize="small" />
|
<OpenInNewIcon fontSize="small" />
|
||||||
@@ -184,10 +185,10 @@ export function ApplicationWorkspace({
|
|||||||
onChange={(_, value: WorkspaceSectionKey) => go(value)}
|
onChange={(_, value: WorkspaceSectionKey) => go(value)}
|
||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
scrollButtons="auto"
|
scrollButtons="auto"
|
||||||
aria-label="Workspace sections"
|
aria-label={t("workspaceSections")}
|
||||||
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
|
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
|
||||||
>
|
>
|
||||||
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={s.label} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -238,16 +239,16 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Tooltip title="Edit application">
|
<Tooltip title={t("workspaceEditApplication")}>
|
||||||
<IconButton size="small" aria-label="Edit application" onClick={onEdit}>
|
<IconButton size="small" aria-label={t("workspaceEditApplication")} onClick={onEdit}>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<EditOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
|
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
|
||||||
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
||||||
{overview.jobUrl && (
|
{overview.jobUrl && (
|
||||||
<Tooltip title="Open original advert">
|
<Tooltip title={t("workspaceOpenAdvert")}>
|
||||||
<IconButton size="small" aria-label="Open original advert" href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
|
<IconButton size="small" aria-label={t("workspaceOpenAdvert")} href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<OpenInNewIcon fontSize="small" />
|
<OpenInNewIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -269,9 +270,9 @@ function ApplicationProgress({ status }: { status: string }) {
|
|||||||
: PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage));
|
: PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 2.5 }} aria-label={`Application progress: ${statusLabel(t, status)}`}>
|
<Box sx={{ mt: 2.5 }} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
|
||||||
Application progress
|
{t("workspaceProgress")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
|
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
|
||||||
{stages.map((stage, index) => {
|
{stages.map((stage, index) => {
|
||||||
@@ -301,6 +302,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const stats = useMemo(() => overview ? [
|
const stats = useMemo(() => overview ? [
|
||||||
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
|
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: "CV", value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? "Tailored text" : "Not prepared"), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
|
||||||
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
{ icon: <MailOutlineIcon fontSize="small" />, label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const },
|
||||||
@@ -317,7 +319,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{overview.nextStep ? (
|
{overview.nextStep ? (
|
||||||
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
|
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
|
||||||
<Typography variant="overline" color="text.secondary">Next recommended action</Typography>
|
<Typography variant="overline" color="text.secondary">{t("workspaceNextAction")}</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>{overview.nextStep.label}</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 800 }}>{overview.nextStep.label}</Typography>
|
||||||
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{overview.nextStep.reason}</Typography>
|
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{overview.nextStep.reason}</Typography>
|
||||||
<Button variant="contained" endIcon={<ArrowForwardIcon />}
|
<Button variant="contained" endIcon={<ArrowForwardIcon />}
|
||||||
@@ -327,7 +329,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
|||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="success" sx={{ borderRadius: 3 }}>
|
<Alert severity="success" sx={{ borderRadius: 3 }}>
|
||||||
Nothing outstanding — this application is fully prepared.
|
{t("workspaceNothingOutstanding")}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -348,12 +350,12 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
|||||||
|
|
||||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Recent activity</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceRecentActivity")}</Typography>
|
||||||
<Button size="small" onClick={onReload}>Refresh</Button>
|
<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Divider sx={{ my: 1 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
{overview.recentActivity.length === 0 ? (
|
{overview.recentActivity.length === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary">No activity recorded yet.</Typography>
|
<Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
{overview.recentActivity.map((a, i) => (
|
{overview.recentActivity.map((a, i) => (
|
||||||
@@ -372,12 +374,13 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
|
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const panels = [
|
const panels = [
|
||||||
{ id: "details", title: "Job details", content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||||
{ id: "tasks", title: "Next actions and checklist", content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
||||||
{ id: "timeline", title: "Activity history", content: <ApplicationTimeline jobId={jobId} /> },
|
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
|
||||||
{ id: "documents", title: "Documents", content: <Attachments jobId={jobId} /> },
|
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
|
||||||
{ id: "communication", title: "Communication", content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -393,6 +396,17 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspaceSectionLabel(t: (key: any) => string, section: WorkspaceSectionKey): string {
|
||||||
|
const keys: Record<WorkspaceSectionKey, any> = {
|
||||||
|
overview: "workspaceOverview",
|
||||||
|
analysis: "workspaceAnalysis",
|
||||||
|
cv: "workspaceCv",
|
||||||
|
"cover-letter": "workspaceCoverLetter",
|
||||||
|
interview: "workspaceInterviewPrep",
|
||||||
|
};
|
||||||
|
return t(keys[section]);
|
||||||
|
}
|
||||||
|
|
||||||
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
function JobDetailsSection({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||||
if (!overview) return <Skeleton variant="rounded" height={200} />;
|
if (!overview) return <Skeleton variant="rounded" height={200} />;
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
|
|||||||
@@ -923,7 +923,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
|||||||
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">Compact</MenuItem><MenuItem value="balanced">Balanced</MenuItem><MenuItem value="roomy">Roomy</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">Compact</MenuItem><MenuItem value="balanced">Balanced</MenuItem><MenuItem value="roomy">Roomy</MenuItem></Select></FormControl>
|
||||||
</>}
|
</>}
|
||||||
<FormControl size="small" fullWidth><InputLabel>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="no">Norwegian</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="nb-NO">Norwegian Bokmål</MenuItem></Select></FormControl>
|
||||||
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>}
|
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>}
|
||||||
|
|||||||
Reference in New Issue
Block a user