fix(i18n): finish career activity copy
This commit is contained in:
@@ -24,6 +24,10 @@ Updated: 2026-08-29
|
||||
- Localized Cover Letter, CV attachment, application-draft, and Interview Prep failure states and document-language selectors. Interview categories now derive from stable category keys while user-authored questions and answers remain unchanged.
|
||||
- Reused the checklist's stable system-key translation mapping for the Overview's recommended next action, preventing the English backend seed text from leaking into a Bokmål workspace.
|
||||
- Corrected the CV Match source caption to interpolate the selected CV name once, localized known match bands, and made intelligence/timeline load failures and dates respect the selected UI locale.
|
||||
- Finished the Career Profile import/review localization pass: queue, cancel, retry, diff, confidence, destructive, save-state, completeness, link-editor, language-selector, and version-history controls now use the global English/Bokmål catalogue while imported CV values remain unchanged.
|
||||
- Localized backend-owned career completeness labels through one shared UI mapping so Overview and Profile display Bokmål without changing the API contract or stored profile data.
|
||||
- Removed the remaining mojibake from the translation catalogue, including account email, Microsoft relink, account-deletion, and learning-path messages.
|
||||
- Localized the Operations and Notifications activity page controls, dates, progress accessibility labels, empty/error/loading states, cancellation feedback, and retry/dismiss actions in English and Norwegian Bokmål.
|
||||
|
||||
### In progress
|
||||
|
||||
@@ -67,6 +71,9 @@ Updated: 2026-08-29
|
||||
- Cover Letter/CV assets and Interview Prep focused verification: 2 suites, 27/27 passed, including Bokmål category rendering that preserves user content.
|
||||
- Workspace/checklist integration verification: 2 suites, 14/14 passed, including localized system-derived next actions.
|
||||
- Application intelligence focused verification: 11/11 passed; TypeScript passed after the creation-tab/localization batch.
|
||||
- Career Profile and Operations focused verification: 2 suites, 16/16 passed, including Bokmål completeness/confidence and operations empty-state regressions.
|
||||
- Full frontend after the Career/Operations localization batch: 62 suites, 260/260 tests passed.
|
||||
- Next optimized production build and its integrated TypeScript check passed after the Career/Operations localization batch.
|
||||
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
|
||||
- Full backend: 712/712 tests passed on .NET 9.
|
||||
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function AiSectionAssistant({ sectionName, text, context, documen
|
||||
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{actionLabel(item.key, item.label, t)}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? t("cvAiWorking") : t("cvAiSuggest")}</Button>
|
||||
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>{t("cvAiClose")}</Button>
|
||||
|
||||
@@ -86,6 +86,17 @@ export const translations = {
|
||||
jobDetails: "Job details",
|
||||
jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.",
|
||||
operations: "Operations",
|
||||
refresh: "Refresh",
|
||||
operationsSubtitle: "Background work survives navigation and refresh.",
|
||||
operationsLoadFailed: "Operations could not be loaded.",
|
||||
operationsActionFailed: "The action could not be completed.",
|
||||
operationsLoading: "Loading operations",
|
||||
operationsNoNotifications: "No notifications.",
|
||||
operationsEmpty: "No background operations yet.",
|
||||
operationsStarted: "Started {date}",
|
||||
operationsProgress: "{task} progress",
|
||||
operationsCancellationRequested: "Cancellation requested.",
|
||||
operationsFailed: "Failed: {category}",
|
||||
gmailReviewQueue: "Gmail review queue",
|
||||
connectedAccounts: "Connected accounts",
|
||||
correspondenceInboxTitle: "Correspondence inbox",
|
||||
@@ -746,6 +757,10 @@ export const translations = {
|
||||
profileImageRemoved: "Profile image removed.",
|
||||
profileImageUploadFailed: "Failed to upload profile image.",
|
||||
profileImageRemoveFailed: "Failed to remove profile image.",
|
||||
profileLoadFailed: "Unable to load profile.",
|
||||
profileProImportTitle: "Build your Career Profile faster with Pro.",
|
||||
profileProImportBody: "Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.",
|
||||
profileUnsavedBeforeActions: "You have unsaved career edits. Save them before running actions that use the stored profile.",
|
||||
profileAccountSection: "Account",
|
||||
profileReadOnlyInfo: "This session is not using a local app token, so profile edits are read-only right now.",
|
||||
profileDisplayName: "Display name",
|
||||
@@ -781,6 +796,51 @@ export const translations = {
|
||||
profileCopyCvText: "Copy CV text",
|
||||
profileCvUploaded: "CV uploaded and processed.",
|
||||
profileCvUploadFailed: "Failed to upload CV.",
|
||||
profileCvQueuedUpload: "Queued CV upload (run {id}).",
|
||||
profileCvQueuedRebuild: "Queued CV rebuild (run {id}).",
|
||||
profileCvQueuedImprove: "Queued CV improvement (run {id}).",
|
||||
profileCvQueuedReprocess: "Queued CV reprocessing (run {id}).",
|
||||
profileCvCancelRequested: "CV processing cancellation requested.",
|
||||
profileCvCancelFailed: "Could not cancel CV processing.",
|
||||
profileCvCancelProcessing: "Cancel processing",
|
||||
profileCvRetryQueued: "CV processing queued again.",
|
||||
profileCvRetryFailed: "Could not retry CV processing.",
|
||||
profileCvRetryProcessing: "Retry processing",
|
||||
profileCvDiffSummary: "{added} additions | {updated} updates",
|
||||
profileCvNeedAttention: "{count} need attention",
|
||||
profileCvNoChangesFound: "No profile changes found",
|
||||
profileCvIncludeLowConfidence: "Include low-confidence {category}: {label}",
|
||||
profileCvSaveBeforeImport: "Save your current career-profile edits before applying imported changes.",
|
||||
profileCvChangesApplied: "CV changes merged into your career profile.",
|
||||
profileCvApplyChangesFailed: "Could not apply CV changes.",
|
||||
profileCvApplyChanges: "Apply changes",
|
||||
profileCvExtractionDiscarded: "CV extraction discarded.",
|
||||
profileCvDiscardFailed: "Could not discard CV extraction.",
|
||||
profileCvDiscard: "Discard",
|
||||
profileCvHideAdvancedTools: "Hide advanced CV tools",
|
||||
profileCvAdvancedTools: "Advanced CV tools",
|
||||
profileCvCompletenessTitle: "Profile completeness",
|
||||
profileCvCompletenessMissing: "Missing:",
|
||||
profileCvCompletenessComplete: "Your career profile is complete.",
|
||||
profileCvCompletenessPersonal: "Personal details",
|
||||
profileCvCompletenessSummary: "Professional summary",
|
||||
profileCvCompletenessExperience: "Experience",
|
||||
profileCvCompletenessEducation: "Education",
|
||||
profileCvCompletenessSkills: "Skills",
|
||||
profileCvCompletenessProjects: "Projects",
|
||||
profileCvCompletenessLanguages: "Languages",
|
||||
profileCvVersionHistory: "Version history ({count})",
|
||||
profileCvVersionCurrent: "current",
|
||||
profileCvVersionRestore: "Restore",
|
||||
profileCvConfidenceReview: "Review",
|
||||
profileCvConfidenceHigh: "High {percent}%",
|
||||
profileCvConfidenceMedium: "Medium {percent}%",
|
||||
profileCvConfidenceLow: "Low {percent}%",
|
||||
profileCvOtherLinks: "Other links",
|
||||
profileCvAddLink: "Add link",
|
||||
profileCvLinkLabel: "Label",
|
||||
profileCvLinkUrl: "URL",
|
||||
profileCvDeleteCustomLink: "Delete custom link {index}",
|
||||
profileCvTextLabel: "Profile CV / master resume text",
|
||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||
profileCvStructuredDefaultHint: "Your career information stays front and center. Open the original import only when you need to check or clean up what was read from your file.",
|
||||
@@ -1894,6 +1954,17 @@ export const translations = {
|
||||
jobDetails: "Jobbdetaljer",
|
||||
jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.",
|
||||
operations: "Operasjoner",
|
||||
refresh: "Oppdater",
|
||||
operationsSubtitle: "Bakgrunnsarbeid fortsetter når du navigerer eller oppdaterer siden.",
|
||||
operationsLoadFailed: "Operasjonene kunne ikke lastes.",
|
||||
operationsActionFailed: "Handlingen kunne ikke fullføres.",
|
||||
operationsLoading: "Laster operasjoner",
|
||||
operationsNoNotifications: "Ingen varsler.",
|
||||
operationsEmpty: "Ingen bakgrunnsoperasjoner ennå.",
|
||||
operationsStarted: "Startet {date}",
|
||||
operationsProgress: "Fremdrift for {task}",
|
||||
operationsCancellationRequested: "Avbrytelse er forespurt.",
|
||||
operationsFailed: "Mislyktes: {category}",
|
||||
gmailReviewQueue: "Gmail-gjennomgang",
|
||||
connectedAccounts: "Tilkoblede kontoer",
|
||||
correspondenceInboxTitle: "Korrespondanseinnboks",
|
||||
@@ -2554,6 +2625,10 @@ export const translations = {
|
||||
profileImageRemoved: "Profilbildet ble fjernet.",
|
||||
profileImageUploadFailed: "Kunne ikke laste opp profilbildet.",
|
||||
profileImageRemoveFailed: "Kunne ikke fjerne profilbildet.",
|
||||
profileLoadFailed: "Kunne ikke laste profilen.",
|
||||
profileProImportTitle: "Bygg karriereprofilen raskere med Pro.",
|
||||
profileProImportBody: "Importer, bygg opp og forbedre CV-innhold med AI. Manuell profilredigering er fortsatt tilgjengelig på Free.",
|
||||
profileUnsavedBeforeActions: "Du har ulagrede karriereendringer. Lagre dem før du kjører handlinger som bruker den lagrede profilen.",
|
||||
profileAccountSection: "Konto",
|
||||
profileReadOnlyInfo: "Denne økten bruker ikke et lokalt apptoken, så profilendringer er skrivebeskyttet akkurat nå.",
|
||||
profileDisplayName: "Visningsnavn",
|
||||
@@ -2565,9 +2640,9 @@ export const translations = {
|
||||
usernameOrEmailRequired: "Brukernavn eller e-post er påkrevd",
|
||||
or: "eller",
|
||||
profileNewEmail: "Ny e-post",
|
||||
profileCurrentEmail: "Nåværende e-post: {email}",
|
||||
profileEmailChangePassword: "Passord for å bekrefte e-postendring",
|
||||
profilePendingEmail: "Venter på bekreftelse fra {email}.",
|
||||
profileCurrentEmail: "Nåværende e-post: {email}",
|
||||
profileEmailChangePassword: "Passord for å bekrefte e-postendring",
|
||||
profilePendingEmail: "Venter på bekreftelse fra {email}.",
|
||||
profileRequestEmailChange: "Send bekreftelse",
|
||||
profileEmailChangeSent: "Bekreftelse sendt til den nye e-postadressen.",
|
||||
profileEmailChangeCancelled: "Ventende e-postendring avbrutt.",
|
||||
@@ -2589,6 +2664,51 @@ export const translations = {
|
||||
profileCopyCvText: "Kopier CV-tekst",
|
||||
profileCvUploaded: "CV lastet opp og behandlet.",
|
||||
profileCvUploadFailed: "Kunne ikke laste opp CV.",
|
||||
profileCvQueuedUpload: "CV-opplasting satt i kø (kjøring {id}).",
|
||||
profileCvQueuedRebuild: "Gjenoppbygging av CV satt i kø (kjøring {id}).",
|
||||
profileCvQueuedImprove: "CV-forbedring satt i kø (kjøring {id}).",
|
||||
profileCvQueuedReprocess: "Ny behandling av CV satt i kø (kjøring {id}).",
|
||||
profileCvCancelRequested: "Avbrytelse av CV-behandling er forespurt.",
|
||||
profileCvCancelFailed: "Kunne ikke avbryte CV-behandlingen.",
|
||||
profileCvCancelProcessing: "Avbryt behandling",
|
||||
profileCvRetryQueued: "CV-behandlingen er satt i kø på nytt.",
|
||||
profileCvRetryFailed: "Kunne ikke prøve CV-behandlingen på nytt.",
|
||||
profileCvRetryProcessing: "Prøv behandlingen på nytt",
|
||||
profileCvDiffSummary: "{added} tillegg | {updated} oppdateringer",
|
||||
profileCvNeedAttention: "{count} trenger gjennomgang",
|
||||
profileCvNoChangesFound: "Ingen profilendringer funnet",
|
||||
profileCvIncludeLowConfidence: "Ta med {category} med lav sikkerhet: {label}",
|
||||
profileCvSaveBeforeImport: "Lagre de nåværende karriereprofilendringene før du bruker importerte endringer.",
|
||||
profileCvChangesApplied: "CV-endringene ble slått sammen med karriereprofilen.",
|
||||
profileCvApplyChangesFailed: "Kunne ikke bruke CV-endringene.",
|
||||
profileCvApplyChanges: "Bruk endringer",
|
||||
profileCvExtractionDiscarded: "CV-uttrekket ble forkastet.",
|
||||
profileCvDiscardFailed: "Kunne ikke forkaste CV-uttrekket.",
|
||||
profileCvDiscard: "Forkast",
|
||||
profileCvHideAdvancedTools: "Skjul avanserte CV-verktøy",
|
||||
profileCvAdvancedTools: "Avanserte CV-verktøy",
|
||||
profileCvCompletenessTitle: "Profilfullstendighet",
|
||||
profileCvCompletenessMissing: "Mangler:",
|
||||
profileCvCompletenessComplete: "Karriereprofilen din er fullstendig.",
|
||||
profileCvCompletenessPersonal: "Personopplysninger",
|
||||
profileCvCompletenessSummary: "Profesjonelt sammendrag",
|
||||
profileCvCompletenessExperience: "Erfaring",
|
||||
profileCvCompletenessEducation: "Utdanning",
|
||||
profileCvCompletenessSkills: "Ferdigheter",
|
||||
profileCvCompletenessProjects: "Prosjekter",
|
||||
profileCvCompletenessLanguages: "Språk",
|
||||
profileCvVersionHistory: "Versjonshistorikk ({count})",
|
||||
profileCvVersionCurrent: "gjeldende",
|
||||
profileCvVersionRestore: "Gjenopprett",
|
||||
profileCvConfidenceReview: "Gjennomgå",
|
||||
profileCvConfidenceHigh: "Høy {percent}%",
|
||||
profileCvConfidenceMedium: "Middels {percent}%",
|
||||
profileCvConfidenceLow: "Lav {percent}%",
|
||||
profileCvOtherLinks: "Andre lenker",
|
||||
profileCvAddLink: "Legg til lenke",
|
||||
profileCvLinkLabel: "Etikett",
|
||||
profileCvLinkUrl: "URL",
|
||||
profileCvDeleteCustomLink: "Slett egendefinert lenke {index}",
|
||||
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
||||
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
||||
profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.",
|
||||
@@ -3134,13 +3254,13 @@ export const translations = {
|
||||
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
|
||||
microsoftUnlinked: "Microsoft-konto koblet fra.",
|
||||
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
|
||||
microsoftSecurityChangeSignInAgain: "Av sikkerhetsgrunner må du logge inn igjen etter å ha endret en koblet konto.",
|
||||
microsoftLegacyRelinkEmailSent: "Sjekk den bekreftede Jobbjakt-e-posten din for å gjenopprette den eldre Microsoft-koblingen.",
|
||||
microsoftSecurityChangeSignInAgain: "Av sikkerhetsgrunner må du logge inn igjen etter å ha endret en koblet konto.",
|
||||
microsoftLegacyRelinkEmailSent: "Sjekk den bekreftede Jobbjakt-e-posten din for å gjenopprette den eldre Microsoft-koblingen.",
|
||||
microsoftLegacyRelinkTitle: "Gjenopprett Microsoft-innlogging",
|
||||
microsoftLegacyRelinkBody: "Autentiser med den samme Microsoft-kontoen som startet gjenopprettingen. Lik e-post alene kobler aldri kontoer.",
|
||||
microsoftLegacyRelinkMissing: "Gjenopprettingslenken er ufullstendig, eller Microsoft-innlogging er ikke konfigurert.",
|
||||
microsoftLegacyRelinkFailed: "Gjenoppretting av Microsoft-konto mislyktes.",
|
||||
microsoftLegacyRelinkSuccess: "Microsoft-innloggingen er koblet på nytt. Du kan nå logge inn som vanlig.",
|
||||
microsoftLegacyRelinkSuccess: "Microsoft-innloggingen er koblet på nytt. Du kan nå logge inn som vanlig.",
|
||||
unlinkMicrosoft: "Koble fra Microsoft",
|
||||
signedOut: "Logget ut.",
|
||||
signedInAs: "Logget inn som {name}.",
|
||||
@@ -3159,14 +3279,14 @@ export const translations = {
|
||||
backupDownloaded: "Sikkerhetskopi lastet ned.",
|
||||
backupFailed: "Sikkerhetskopiering mislyktes.",
|
||||
accountDeleteTitle: "Slett konto",
|
||||
accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.",
|
||||
accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for å fortsette.",
|
||||
accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.",
|
||||
accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for å fortsette.",
|
||||
accountDeleteButton: "Slett kontoen min",
|
||||
accountDeleteStarting: "Starter sletting...",
|
||||
accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.",
|
||||
accountDeleteFailed: "Kontosletting kunne ikke startes.",
|
||||
accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.",
|
||||
accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.",
|
||||
accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.",
|
||||
accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.",
|
||||
authStatusTitle: "Autentisering",
|
||||
authStatusNotSignedIn: "Ikke logget inn.",
|
||||
authStatusRoles: "Roller: {roles}",
|
||||
@@ -3449,13 +3569,13 @@ export const translations = {
|
||||
matchScoreNoneYet: "Ingen treff ennå.",
|
||||
matchScoreAllCovered: "Alle viktige begreper er dekket.",
|
||||
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
|
||||
matchScoreLearningPath: "Læringssti",
|
||||
matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.",
|
||||
matchScoreMarkLearned: "Marker som lært",
|
||||
matchScoreLearned: "Lært",
|
||||
matchScoreLearningPath: "Læringssti",
|
||||
matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.",
|
||||
matchScoreMarkLearned: "Marker som lært",
|
||||
matchScoreLearned: "Lært",
|
||||
matchScoreDismiss: "Avvis",
|
||||
matchScoreDismissedCount: "{count} avvist",
|
||||
matchScoreLearningUpdateFailed: "Kunne ikke oppdatere læringsanbefalingen.",
|
||||
matchScoreLearningUpdateFailed: "Kunne ikke oppdatere læringsanbefalingen.",
|
||||
jobDetailsStrategySnapshot: "Strategioversikt",
|
||||
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
||||
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
||||
|
||||
@@ -9,7 +9,7 @@ export type UserNotification = {
|
||||
readAtUtc?: string | null;
|
||||
};
|
||||
|
||||
export function notificationDateLabel(value: string): string {
|
||||
export function notificationDateLabel(value: string, locale?: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(locale);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import OperationsPage from "./views/OperationsPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
@@ -17,6 +18,10 @@ jest.mock("./api", () => ({
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
return render(<I18nProvider><OperationsPage /></I18nProvider>);
|
||||
}
|
||||
|
||||
const operation = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
taskType: "strategy_snapshot",
|
||||
@@ -40,6 +45,7 @@ const notification = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
|
||||
data: url.startsWith("/operations") ? [operation] : [notification],
|
||||
} as any));
|
||||
@@ -48,7 +54,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test("shows persistent operation and notification states with accessible actions", async () => {
|
||||
render(<OperationsPage />);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
|
||||
expect(screen.getByText("Analysing role")).toBeInTheDocument();
|
||||
@@ -71,12 +77,23 @@ test("shows persistent operation and notification states with accessible actions
|
||||
|
||||
test("shows honest empty and failure states", async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [] } as any).mockResolvedValueOnce({ data: [] } as any);
|
||||
const { unmount } = render(<OperationsPage />);
|
||||
const { unmount } = renderPage();
|
||||
expect(await screen.findByText("No notifications.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No background operations yet.")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
mockedApi.get.mockRejectedValue(new Error("offline"));
|
||||
render(<OperationsPage />);
|
||||
renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
});
|
||||
|
||||
test("localizes operation controls and empty states in Norwegian Bokmål", async () => {
|
||||
window.localStorage.setItem("uiLanguage", "nb");
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Ingen varsler.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ingen bakgrunnsoperasjoner ennå.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Oppdater" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -416,6 +416,16 @@ test('/career shows the profile completeness overview', async () => {
|
||||
expect(await screen.findByText(/70%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('/career localizes completeness and review confidence without changing CV data', async () => {
|
||||
window.localStorage.setItem('uiLanguage', 'nb');
|
||||
renderWith(CareerProfilePage);
|
||||
|
||||
expect(await screen.findByText(/Legg til neste: Prosjekter/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Karriereprofil' }));
|
||||
expect(screen.getByText(/Høy 92%/i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Demo User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('/career separates overview, profile and import while preserving unsaved profile edits', async () => {
|
||||
renderWith(CareerProfilePage);
|
||||
await screen.findByText(/70%/);
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function CareerProfilePage() {
|
||||
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
|
||||
const careerOnly = true;
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { language, t } = useI18n();
|
||||
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
@@ -393,8 +393,8 @@ export default function CareerProfilePage() {
|
||||
/> : null}
|
||||
|
||||
{loadError ? (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
|
||||
Unable to load profile.
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
|
||||
{t("profileLoadFailed")}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -468,12 +468,12 @@ export default function CareerProfilePage() {
|
||||
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
||||
{!canUseAi && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
|
||||
Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.
|
||||
<ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>
|
||||
{t("profileProImportBody")}
|
||||
</ProFeatureNotice>
|
||||
</Box>
|
||||
)}
|
||||
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>You have unsaved career edits. Save them before running actions that use the stored profile.</Alert> : null}
|
||||
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>{t("profileUnsavedBeforeActions")}</Alert> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
||||
@@ -497,7 +497,7 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
|
||||
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
||||
} finally {
|
||||
@@ -516,7 +516,7 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
|
||||
toast(t("profileCvQueuedRebuild", { id: res.data.extractionRunId }), "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
|
||||
} finally {
|
||||
@@ -534,7 +534,7 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
|
||||
toast(t("profileCvQueuedImprove", { id: res.data.extractionRunId }), "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
||||
} finally {
|
||||
@@ -552,7 +552,7 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
|
||||
toast(t("profileCvQueuedReprocess", { id: res.data.extractionRunId }), "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
|
||||
} finally {
|
||||
@@ -617,7 +617,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
|
||||
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()}
|
||||
{run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
|
||||
</Typography>
|
||||
{run.errorMessage ? (
|
||||
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
|
||||
@@ -629,36 +629,36 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
await api.post(`/operations/${run.operation!.id}/cancel`);
|
||||
await loadExtractionRuns();
|
||||
toast("CV processing cancellation requested.", "info");
|
||||
toast(t("profileCvCancelRequested"), "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
|
||||
toast(getApiErrorMessage(error, t("profileCvCancelFailed")), "error");
|
||||
}
|
||||
}}>Cancel processing</Button>
|
||||
}}>{t("profileCvCancelProcessing")}</Button>
|
||||
) : null}
|
||||
{run.operation?.canRetry ? (
|
||||
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
|
||||
try {
|
||||
await api.post(`/operations/${run.operation!.id}/retry`);
|
||||
await loadExtractionRuns();
|
||||
toast("CV processing queued again.", "info");
|
||||
toast(t("profileCvRetryQueued"), "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
|
||||
toast(getApiErrorMessage(error, t("profileCvRetryFailed")), "error");
|
||||
}
|
||||
}}>Retry processing</Button>
|
||||
}}>{t("profileCvRetryProcessing")}</Button>
|
||||
) : null}
|
||||
{run.status === "pending_review" ? (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
{runDiffs[run.id] ? (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates
|
||||
{runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""}
|
||||
{t("profileCvDiffSummary", { added: runDiffs[run.id].totalAdded, updated: runDiffs[run.id].totalUpdated })}
|
||||
{runDiffs[run.id].totalLowConfidence ? ` | ${t("profileCvNeedAttention", { count: runDiffs[run.id].totalLowConfidence })}` : ""}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||
{runDiffs[run.id].categories
|
||||
.filter((category) => category.added.length || category.updated.length)
|
||||
.map((category) => `${category.category}: +${category.added.length} / ~${category.updated.length}`)
|
||||
.join(" | ") || "No profile changes found"}
|
||||
.join(" | ") || t("profileCvNoChangesFound")}
|
||||
</Typography>
|
||||
</>
|
||||
) : <LinearProgress sx={{ my: 1 }} />}
|
||||
@@ -672,12 +672,12 @@ export default function CareerProfilePage() {
|
||||
? [...(current[run.id] ?? []), change.id]
|
||||
: (current[run.id] ?? []).filter((id) => id !== change.id),
|
||||
}))} />}
|
||||
label={`Include low-confidence ${change.category}: ${change.label}`}
|
||||
label={t("profileCvIncludeLowConfidence", { category: change.category, label: change.label })}
|
||||
/>
|
||||
))}
|
||||
{profileDirty ? (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Save your current career-profile edits before applying imported changes.
|
||||
{t("profileCvSaveBeforeImport")}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||
@@ -687,22 +687,22 @@ export default function CareerProfilePage() {
|
||||
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
|
||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
|
||||
toast("CV changes merged into your career profile.", "success");
|
||||
toast(t("profileCvChangesApplied"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
|
||||
toast(getApiErrorMessage(error, t("profileCvApplyChangesFailed")), "error");
|
||||
} finally { setReviewingRunId(null); }
|
||||
}}>Apply changes</Button>
|
||||
}}>{t("profileCvApplyChanges")}</Button>
|
||||
<Button size="small" color="inherit" disabled={reviewingRunId !== null} onClick={async () => {
|
||||
setReviewingRunId(run.id);
|
||||
try {
|
||||
await api.post(`/profile-cv/runs/${run.id}/discard`);
|
||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||
await loadExtractionRuns();
|
||||
toast("CV extraction discarded.", "info");
|
||||
toast(t("profileCvExtractionDiscarded"), "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
|
||||
toast(getApiErrorMessage(error, t("profileCvDiscardFailed")), "error");
|
||||
} finally { setReviewingRunId(null); }
|
||||
}}>Discard</Button>
|
||||
}}>{t("profileCvDiscard")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
@@ -715,7 +715,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Button size="small" variant="text" color="inherit" onClick={() => setShowAdvancedCvTools((v) => !v)}>
|
||||
{showAdvancedCvTools ? "Hide advanced CV tools" : "Advanced CV tools"}
|
||||
{showAdvancedCvTools ? t("profileCvHideAdvancedTools") : t("profileCvAdvancedTools")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: showAdvancedCvTools ? "block" : "none" }}>
|
||||
@@ -794,7 +794,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<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
|
||||
{t("profileCvSectionWordCount", { count: cvWordCount })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("profileCvPreferredUploads")}
|
||||
@@ -803,7 +803,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
|
||||
<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}
|
||||
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || loading}
|
||||
|
||||
@@ -701,8 +701,8 @@ function DocumentAiTab({ settings, outline, update }: {
|
||||
</Box>
|
||||
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
|
||||
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
<Stack spacing={1}>
|
||||
{actions.map((action) => (
|
||||
@@ -1190,7 +1190,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorPageSize")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorPageSize") }} label={t("cvEditorPageSize")} 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>{t("cvEditorDensity")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDensity") }} label={t("cvEditorDensity")} value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">{t("cvEditorCompact")}</MenuItem><MenuItem value="balanced">{t("cvEditorBalanced")}</MenuItem><MenuItem value="roomy">{t("cvEditorRoomy")}</MenuItem></Select></FormControl>
|
||||
</>}
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} 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">Norsk bokmål</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} value={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">{t("languageEnglish")}</MenuItem><MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDateFormat")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDateFormat") }} label={t("cvEditorDateFormat")} 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>
|
||||
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorColumns")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorColumns") }} label={t("cvEditorColumns")} value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="single">{t("cvEditorOneColumn")}</MenuItem><MenuItem value="header-band">{t("cvEditorHeaderBandColumn")}</MenuItem><MenuItem value="sidebar-left">{t("cvEditorLeftSidebar")}</MenuItem><MenuItem value="sidebar-right">{t("cvEditorRightSidebar")}</MenuItem></Select></FormControl>}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { notificationDateLabel, UserNotification } from "../notifications";
|
||||
|
||||
type Operation = {
|
||||
@@ -30,9 +31,9 @@ type Operation = {
|
||||
};
|
||||
|
||||
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
||||
const dateLabel = notificationDateLabel;
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { language, t } = useI18n();
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,11 +51,11 @@ export default function OperationsPage() {
|
||||
setNotifications(notificationResponse.data ?? []);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, "Operations could not be loaded."));
|
||||
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -70,7 +71,7 @@ export default function OperationsPage() {
|
||||
await load();
|
||||
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, "The action could not be completed."));
|
||||
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
@@ -79,30 +80,30 @@ export default function OperationsPage() {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography color="text.secondary">Background work survives navigation and refresh.</Typography>
|
||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>Refresh</Button>
|
||||
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
|
||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
|
||||
</Box>
|
||||
|
||||
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label="Loading operations" /> : null}
|
||||
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
|
||||
|
||||
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>Notifications</Typography>
|
||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">No notifications.</Typography> : null}
|
||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
|
||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{notifications.map((notification) => (
|
||||
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
|
||||
<Typography color="text.secondary">{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? (
|
||||
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
|
||||
Mark read
|
||||
{t("notificationsMarkRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
||||
Dismiss
|
||||
{t("notificationsDismiss")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -111,8 +112,8 @@ export default function OperationsPage() {
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>Operations</Typography>
|
||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">No background operations yet.</Typography> : null}
|
||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
|
||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{operations.map((operation) => (
|
||||
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
@@ -120,20 +121,20 @@ export default function OperationsPage() {
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
|
||||
<Chip size="small" label={statusLabel(operation.status)} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">Started {dateLabel(operation.createdAtUtc)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })}</Typography>
|
||||
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={`${operation.taskType} progress`} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>Cancellation requested.</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>Failed: {statusLabel(operation.failureCategory)}</Alert> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: operation.taskType })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>{t("operationsFailed", { category: statusLabel(operation.failureCategory) })}</Alert> : null}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? (
|
||||
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
{operation.canRetry ? (
|
||||
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
|
||||
Retry
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -30,18 +30,22 @@ type MetadataLookup = (path: string) => StructuredCvFieldMetadata | undefined;
|
||||
// Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent
|
||||
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
|
||||
function confidenceTone(confidence?: number) {
|
||||
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
||||
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
||||
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
|
||||
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
|
||||
if (typeof confidence !== "number") return { key: "profileCvConfidenceReview" as const, color: "default" as const };
|
||||
if (confidence >= 0.8) return { key: "profileCvConfidenceHigh" as const, color: "success" as const };
|
||||
if (confidence >= 0.65) return { key: "profileCvConfidenceMedium" as const, color: "warning" as const };
|
||||
return { key: "profileCvConfidenceLow" as const, color: "error" as const };
|
||||
}
|
||||
|
||||
export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
|
||||
const { t } = useI18n();
|
||||
if (!metadata) return null;
|
||||
const tone = confidenceTone(metadata.confidence);
|
||||
const label = typeof metadata.confidence === "number"
|
||||
? t(tone.key, { percent: Math.round(metadata.confidence * 100) })
|
||||
: t(tone.key);
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
||||
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
||||
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={label} />
|
||||
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
||||
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
||||
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
||||
@@ -91,8 +95,8 @@ export function PersonalInformationSection({
|
||||
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">Other links</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>Add link</Button></Stack>
|
||||
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label="Label" value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label="URL" value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={`Delete custom link ${index + 1}`} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
|
||||
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
||||
import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder";
|
||||
import type { UserOperation } from "../../types";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
|
||||
|
||||
export type CareerWorkspaceCompleteness = {
|
||||
percent: number;
|
||||
@@ -123,7 +124,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
</Box>
|
||||
<LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent ?? 0} sx={{ mt: 1, height: 8, borderRadius: 999 }} />
|
||||
{!loading && completeness?.missing.length ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).join(", ") })}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).map((label) => localizeCompletenessLabel(label, t)).join(", ") })}</Typography>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
|
||||
|
||||
// Display-only slice of the Career Profile page: the completeness meter, the "missing" chips, and the
|
||||
// version-history accordion. Extracted from CareerProfilePage as the first step of the Phase 1
|
||||
@@ -31,14 +33,17 @@ export default function ProfileCompleteness({
|
||||
onRestore: (version: number) => void;
|
||||
showSummary?: boolean;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
if ((!showSummary || !completeness) && versions.length <= 1) return null;
|
||||
|
||||
const locale = language === "nb" ? "nb-NO" : "en";
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
{showSummary && completeness ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>{t("profileCvCompletenessTitle")}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
@@ -49,30 +54,30 @@ export default function ProfileCompleteness({
|
||||
/>
|
||||
{completeness.missing.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvCompletenessMissing")}</Typography>
|
||||
{completeness.missing.map((label) => (
|
||||
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
|
||||
<Chip key={label} size="small" label={localizeCompletenessLabel(label, t)} sx={{ height: 22, fontWeight: 700 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your career profile is complete.</Typography>
|
||||
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>{t("profileCvCompletenessComplete")}</Typography>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{versions.length > 1 ? (
|
||||
<Accordion disableGutters elevation={0} sx={{ mt: showSummary ? 1.5 : 0, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Version history ({versions.length})</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("profileCvVersionHistory", { count: versions.length })}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 0, pt: 0 }}>
|
||||
<Box sx={{ display: "grid", gap: 0.75 }}>
|
||||
{versions.slice(0, 12).map((v) => (
|
||||
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString()}{v.isCurrent ? " · current" : ""}
|
||||
v{v.version} · {v.source} · {new Date(v.createdAtUtc).toLocaleString(locale)}{v.isCurrent ? ` · ${t("profileCvVersionCurrent")}` : ""}
|
||||
</Typography>
|
||||
{!v.isCurrent ? (
|
||||
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>Restore</Button>
|
||||
<Button size="small" variant="text" disabled={loading} onClick={() => onRestore(v.version)}>{t("profileCvVersionRestore")}</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TranslationKey } from "../../i18n/translations";
|
||||
|
||||
const completenessLabelKeys: Record<string, TranslationKey> = {
|
||||
"Personal details": "profileCvCompletenessPersonal",
|
||||
"Professional summary": "profileCvCompletenessSummary",
|
||||
Experience: "profileCvCompletenessExperience",
|
||||
Education: "profileCvCompletenessEducation",
|
||||
Skills: "profileCvCompletenessSkills",
|
||||
Projects: "profileCvCompletenessProjects",
|
||||
Languages: "profileCvCompletenessLanguages",
|
||||
};
|
||||
|
||||
export function localizeCompletenessLabel(label: string, t: (key: TranslationKey) => string) {
|
||||
const key = completenessLabelKeys[label];
|
||||
return key ? t(key) : label;
|
||||
}
|
||||
Reference in New Issue
Block a user