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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Full backend: 712/712 tests passed on .NET 9.
|
||||||
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
|
- 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>)}
|
{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>
|
||||||
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
|
<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="en">{t("languageEnglish")}</MenuItem>
|
||||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? t("cvAiWorking") : t("cvAiSuggest")}</Button>
|
<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>
|
<Button size="small" onClick={() => { setOpen(false); setSuggestion(""); }}>{t("cvAiClose")}</Button>
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ export const translations = {
|
|||||||
jobDetails: "Job details",
|
jobDetails: "Job details",
|
||||||
jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.",
|
jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.",
|
||||||
operations: "Operations",
|
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",
|
gmailReviewQueue: "Gmail review queue",
|
||||||
connectedAccounts: "Connected accounts",
|
connectedAccounts: "Connected accounts",
|
||||||
correspondenceInboxTitle: "Correspondence inbox",
|
correspondenceInboxTitle: "Correspondence inbox",
|
||||||
@@ -746,6 +757,10 @@ export const translations = {
|
|||||||
profileImageRemoved: "Profile image removed.",
|
profileImageRemoved: "Profile image removed.",
|
||||||
profileImageUploadFailed: "Failed to upload profile image.",
|
profileImageUploadFailed: "Failed to upload profile image.",
|
||||||
profileImageRemoveFailed: "Failed to remove 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",
|
profileAccountSection: "Account",
|
||||||
profileReadOnlyInfo: "This session is not using a local app token, so profile edits are read-only right now.",
|
profileReadOnlyInfo: "This session is not using a local app token, so profile edits are read-only right now.",
|
||||||
profileDisplayName: "Display name",
|
profileDisplayName: "Display name",
|
||||||
@@ -781,6 +796,51 @@ export const translations = {
|
|||||||
profileCopyCvText: "Copy CV text",
|
profileCopyCvText: "Copy CV text",
|
||||||
profileCvUploaded: "CV uploaded and processed.",
|
profileCvUploaded: "CV uploaded and processed.",
|
||||||
profileCvUploadFailed: "Failed to upload CV.",
|
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",
|
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.",
|
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.",
|
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",
|
jobDetails: "Jobbdetaljer",
|
||||||
jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.",
|
jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.",
|
||||||
operations: "Operasjoner",
|
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",
|
gmailReviewQueue: "Gmail-gjennomgang",
|
||||||
connectedAccounts: "Tilkoblede kontoer",
|
connectedAccounts: "Tilkoblede kontoer",
|
||||||
correspondenceInboxTitle: "Korrespondanseinnboks",
|
correspondenceInboxTitle: "Korrespondanseinnboks",
|
||||||
@@ -2554,6 +2625,10 @@ export const translations = {
|
|||||||
profileImageRemoved: "Profilbildet ble fjernet.",
|
profileImageRemoved: "Profilbildet ble fjernet.",
|
||||||
profileImageUploadFailed: "Kunne ikke laste opp profilbildet.",
|
profileImageUploadFailed: "Kunne ikke laste opp profilbildet.",
|
||||||
profileImageRemoveFailed: "Kunne ikke fjerne 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",
|
profileAccountSection: "Konto",
|
||||||
profileReadOnlyInfo: "Denne økten bruker ikke et lokalt apptoken, så profilendringer er skrivebeskyttet akkurat nå.",
|
profileReadOnlyInfo: "Denne økten bruker ikke et lokalt apptoken, så profilendringer er skrivebeskyttet akkurat nå.",
|
||||||
profileDisplayName: "Visningsnavn",
|
profileDisplayName: "Visningsnavn",
|
||||||
@@ -2565,9 +2640,9 @@ export const translations = {
|
|||||||
usernameOrEmailRequired: "Brukernavn eller e-post er påkrevd",
|
usernameOrEmailRequired: "Brukernavn eller e-post er påkrevd",
|
||||||
or: "eller",
|
or: "eller",
|
||||||
profileNewEmail: "Ny e-post",
|
profileNewEmail: "Ny e-post",
|
||||||
profileCurrentEmail: "Nåværende e-post: {email}",
|
profileCurrentEmail: "Nåværende e-post: {email}",
|
||||||
profileEmailChangePassword: "Passord for å bekrefte e-postendring",
|
profileEmailChangePassword: "Passord for å bekrefte e-postendring",
|
||||||
profilePendingEmail: "Venter på bekreftelse fra {email}.",
|
profilePendingEmail: "Venter på bekreftelse fra {email}.",
|
||||||
profileRequestEmailChange: "Send bekreftelse",
|
profileRequestEmailChange: "Send bekreftelse",
|
||||||
profileEmailChangeSent: "Bekreftelse sendt til den nye e-postadressen.",
|
profileEmailChangeSent: "Bekreftelse sendt til den nye e-postadressen.",
|
||||||
profileEmailChangeCancelled: "Ventende e-postendring avbrutt.",
|
profileEmailChangeCancelled: "Ventende e-postendring avbrutt.",
|
||||||
@@ -2589,6 +2664,51 @@ export const translations = {
|
|||||||
profileCopyCvText: "Kopier CV-tekst",
|
profileCopyCvText: "Kopier CV-tekst",
|
||||||
profileCvUploaded: "CV lastet opp og behandlet.",
|
profileCvUploaded: "CV lastet opp og behandlet.",
|
||||||
profileCvUploadFailed: "Kunne ikke laste opp CV.",
|
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",
|
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.",
|
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.",
|
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.",
|
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
|
||||||
microsoftUnlinked: "Microsoft-konto koblet fra.",
|
microsoftUnlinked: "Microsoft-konto koblet fra.",
|
||||||
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
|
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
|
||||||
microsoftSecurityChangeSignInAgain: "Av sikkerhetsgrunner må du logge inn igjen etter å ha endret en koblet konto.",
|
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.",
|
microsoftLegacyRelinkEmailSent: "Sjekk den bekreftede Jobbjakt-e-posten din for å gjenopprette den eldre Microsoft-koblingen.",
|
||||||
microsoftLegacyRelinkTitle: "Gjenopprett Microsoft-innlogging",
|
microsoftLegacyRelinkTitle: "Gjenopprett Microsoft-innlogging",
|
||||||
microsoftLegacyRelinkBody: "Autentiser med den samme Microsoft-kontoen som startet gjenopprettingen. Lik e-post alene kobler aldri kontoer.",
|
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.",
|
microsoftLegacyRelinkMissing: "Gjenopprettingslenken er ufullstendig, eller Microsoft-innlogging er ikke konfigurert.",
|
||||||
microsoftLegacyRelinkFailed: "Gjenoppretting av Microsoft-konto mislyktes.",
|
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",
|
unlinkMicrosoft: "Koble fra Microsoft",
|
||||||
signedOut: "Logget ut.",
|
signedOut: "Logget ut.",
|
||||||
signedInAs: "Logget inn som {name}.",
|
signedInAs: "Logget inn som {name}.",
|
||||||
@@ -3159,14 +3279,14 @@ export const translations = {
|
|||||||
backupDownloaded: "Sikkerhetskopi lastet ned.",
|
backupDownloaded: "Sikkerhetskopi lastet ned.",
|
||||||
backupFailed: "Sikkerhetskopiering mislyktes.",
|
backupFailed: "Sikkerhetskopiering mislyktes.",
|
||||||
accountDeleteTitle: "Slett konto",
|
accountDeleteTitle: "Slett konto",
|
||||||
accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.",
|
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.",
|
accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for å fortsette.",
|
||||||
accountDeleteButton: "Slett kontoen min",
|
accountDeleteButton: "Slett kontoen min",
|
||||||
accountDeleteStarting: "Starter sletting...",
|
accountDeleteStarting: "Starter sletting...",
|
||||||
accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.",
|
accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.",
|
||||||
accountDeleteFailed: "Kontosletting kunne ikke startes.",
|
accountDeleteFailed: "Kontosletting kunne ikke startes.",
|
||||||
accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.",
|
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.",
|
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",
|
authStatusTitle: "Autentisering",
|
||||||
authStatusNotSignedIn: "Ikke logget inn.",
|
authStatusNotSignedIn: "Ikke logget inn.",
|
||||||
authStatusRoles: "Roller: {roles}",
|
authStatusRoles: "Roller: {roles}",
|
||||||
@@ -3449,13 +3569,13 @@ export const translations = {
|
|||||||
matchScoreNoneYet: "Ingen treff ennå.",
|
matchScoreNoneYet: "Ingen treff ennå.",
|
||||||
matchScoreAllCovered: "Alle viktige begreper er dekket.",
|
matchScoreAllCovered: "Alle viktige begreper er dekket.",
|
||||||
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
|
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
|
||||||
matchScoreLearningPath: "Læringssti",
|
matchScoreLearningPath: "Læringssti",
|
||||||
matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.",
|
matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.",
|
||||||
matchScoreMarkLearned: "Marker som lært",
|
matchScoreMarkLearned: "Marker som lært",
|
||||||
matchScoreLearned: "Lært",
|
matchScoreLearned: "Lært",
|
||||||
matchScoreDismiss: "Avvis",
|
matchScoreDismiss: "Avvis",
|
||||||
matchScoreDismissedCount: "{count} avvist",
|
matchScoreDismissedCount: "{count} avvist",
|
||||||
matchScoreLearningUpdateFailed: "Kunne ikke oppdatere læringsanbefalingen.",
|
matchScoreLearningUpdateFailed: "Kunne ikke oppdatere læringsanbefalingen.",
|
||||||
jobDetailsStrategySnapshot: "Strategioversikt",
|
jobDetailsStrategySnapshot: "Strategioversikt",
|
||||||
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
||||||
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type UserNotification = {
|
|||||||
readAtUtc?: string | null;
|
readAtUtc?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function notificationDateLabel(value: string): string {
|
export function notificationDateLabel(value: string, locale?: string): string {
|
||||||
const date = new Date(value);
|
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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
import OperationsPage from "./views/OperationsPage";
|
import OperationsPage from "./views/OperationsPage";
|
||||||
|
|
||||||
jest.mock("./api", () => ({
|
jest.mock("./api", () => ({
|
||||||
@@ -17,6 +18,10 @@ jest.mock("./api", () => ({
|
|||||||
|
|
||||||
const mockedApi = api as jest.Mocked<typeof api>;
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(<I18nProvider><OperationsPage /></I18nProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
const operation = {
|
const operation = {
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
id: "11111111-1111-1111-1111-111111111111",
|
||||||
taskType: "strategy_snapshot",
|
taskType: "strategy_snapshot",
|
||||||
@@ -40,6 +45,7 @@ const notification = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
|
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
|
||||||
data: url.startsWith("/operations") ? [operation] : [notification],
|
data: url.startsWith("/operations") ? [operation] : [notification],
|
||||||
} as any));
|
} as any));
|
||||||
@@ -48,7 +54,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("shows persistent operation and notification states with accessible actions", async () => {
|
test("shows persistent operation and notification states with accessible actions", async () => {
|
||||||
render(<OperationsPage />);
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
|
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Analysing role")).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 () => {
|
test("shows honest empty and failure states", async () => {
|
||||||
mockedApi.get.mockResolvedValueOnce({ data: [] } as any).mockResolvedValueOnce({ data: [] } as any);
|
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(await screen.findByText("No notifications.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("No background operations yet.")).toBeInTheDocument();
|
expect(screen.getByText("No background operations yet.")).toBeInTheDocument();
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
mockedApi.get.mockRejectedValue(new Error("offline"));
|
mockedApi.get.mockRejectedValue(new Error("offline"));
|
||||||
render(<OperationsPage />);
|
renderPage();
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
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();
|
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 () => {
|
test('/career separates overview, profile and import while preserving unsaved profile edits', async () => {
|
||||||
renderWith(CareerProfilePage);
|
renderWith(CareerProfilePage);
|
||||||
await screen.findByText(/70%/);
|
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.
|
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
|
||||||
const careerOnly = true;
|
const careerOnly = true;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [me, setMe] = useState<MeResponse | null>(null);
|
const [me, setMe] = useState<MeResponse | null>(null);
|
||||||
@@ -393,8 +393,8 @@ export default function CareerProfilePage() {
|
|||||||
/> : null}
|
/> : null}
|
||||||
|
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}>
|
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
|
||||||
Unable to load profile.
|
{t("profileLoadFailed")}
|
||||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -468,12 +468,12 @@ export default function CareerProfilePage() {
|
|||||||
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
<Box sx={{ display: workspaceSection === "import" ? "block" : "none" }}>
|
||||||
{!canUseAi && (
|
{!canUseAi && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
|
<ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>
|
||||||
Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.
|
{t("profileProImportBody")}
|
||||||
</ProFeatureNotice>
|
</ProFeatureNotice>
|
||||||
</Box>
|
</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 sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
||||||
@@ -497,7 +497,7 @@ export default function CareerProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
|
toast(t("profileCvQueuedUpload", { id: res.data.extractionRunId }), "info");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -516,7 +516,7 @@ export default function CareerProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
|
toast(t("profileCvQueuedRebuild", { id: res.data.extractionRunId }), "info");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
|
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -534,7 +534,7 @@ export default function CareerProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
|
toast(t("profileCvQueuedImprove", { id: res.data.extractionRunId }), "info");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -552,7 +552,7 @@ export default function CareerProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
|
toast(t("profileCvQueuedReprocess", { id: res.data.extractionRunId }), "info");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
|
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -617,7 +617,7 @@ export default function CareerProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{run.artifactFileName || t("profileCvNoStoredArtifact")}</Typography>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75 }}>
|
<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>
|
</Typography>
|
||||||
{run.errorMessage ? (
|
{run.errorMessage ? (
|
||||||
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
|
<Typography variant="caption" sx={{ color: "error.main", display: "block", mt: 0.75 }}>
|
||||||
@@ -629,36 +629,36 @@ export default function CareerProfilePage() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/operations/${run.operation!.id}/cancel`);
|
await api.post(`/operations/${run.operation!.id}/cancel`);
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast("CV processing cancellation requested.", "info");
|
toast(t("profileCvCancelRequested"), "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
|
toast(getApiErrorMessage(error, t("profileCvCancelFailed")), "error");
|
||||||
}
|
}
|
||||||
}}>Cancel processing</Button>
|
}}>{t("profileCvCancelProcessing")}</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{run.operation?.canRetry ? (
|
{run.operation?.canRetry ? (
|
||||||
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
|
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await api.post(`/operations/${run.operation!.id}/retry`);
|
await api.post(`/operations/${run.operation!.id}/retry`);
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast("CV processing queued again.", "info");
|
toast(t("profileCvRetryQueued"), "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
|
toast(getApiErrorMessage(error, t("profileCvRetryFailed")), "error");
|
||||||
}
|
}
|
||||||
}}>Retry processing</Button>
|
}}>{t("profileCvRetryProcessing")}</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{run.status === "pending_review" ? (
|
{run.status === "pending_review" ? (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
{runDiffs[run.id] ? (
|
{runDiffs[run.id] ? (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
{runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates
|
{t("profileCvDiffSummary", { added: runDiffs[run.id].totalAdded, updated: runDiffs[run.id].totalUpdated })}
|
||||||
{runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""}
|
{runDiffs[run.id].totalLowConfidence ? ` | ${t("profileCvNeedAttention", { count: runDiffs[run.id].totalLowConfidence })}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||||
{runDiffs[run.id].categories
|
{runDiffs[run.id].categories
|
||||||
.filter((category) => category.added.length || category.updated.length)
|
.filter((category) => category.added.length || category.updated.length)
|
||||||
.map((category) => `${category.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>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
) : <LinearProgress sx={{ my: 1 }} />}
|
) : <LinearProgress sx={{ my: 1 }} />}
|
||||||
@@ -672,12 +672,12 @@ export default function CareerProfilePage() {
|
|||||||
? [...(current[run.id] ?? []), change.id]
|
? [...(current[run.id] ?? []), change.id]
|
||||||
: (current[run.id] ?? []).filter((id) => 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 ? (
|
{profileDirty ? (
|
||||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||||
Save your current career-profile edits before applying imported changes.
|
{t("profileCvSaveBeforeImport")}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
<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] ?? [] });
|
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
|
||||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||||
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
|
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
|
||||||
toast("CV changes merged into your career profile.", "success");
|
toast(t("profileCvChangesApplied"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
|
toast(getApiErrorMessage(error, t("profileCvApplyChangesFailed")), "error");
|
||||||
} finally { setReviewingRunId(null); }
|
} finally { setReviewingRunId(null); }
|
||||||
}}>Apply changes</Button>
|
}}>{t("profileCvApplyChanges")}</Button>
|
||||||
<Button size="small" color="inherit" disabled={reviewingRunId !== null} onClick={async () => {
|
<Button size="small" color="inherit" disabled={reviewingRunId !== null} onClick={async () => {
|
||||||
setReviewingRunId(run.id);
|
setReviewingRunId(run.id);
|
||||||
try {
|
try {
|
||||||
await api.post(`/profile-cv/runs/${run.id}/discard`);
|
await api.post(`/profile-cv/runs/${run.id}/discard`);
|
||||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||||
await loadExtractionRuns();
|
await loadExtractionRuns();
|
||||||
toast("CV extraction discarded.", "info");
|
toast(t("profileCvExtractionDiscarded"), "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
|
toast(getApiErrorMessage(error, t("profileCvDiscardFailed")), "error");
|
||||||
} finally { setReviewingRunId(null); }
|
} finally { setReviewingRunId(null); }
|
||||||
}}>Discard</Button>
|
}}>{t("profileCvDiscard")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -715,7 +715,7 @@ export default function CareerProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||||
<Button size="small" variant="text" color="inherit" onClick={() => setShowAdvancedCvTools((v) => !v)}>
|
<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>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: showAdvancedCvTools ? "block" : "none" }}>
|
<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>
|
||||||
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{cvWordCount} words
|
{t("profileCvSectionWordCount", { count: cvWordCount })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{t("profileCvPreferredUploads")}
|
{t("profileCvPreferredUploads")}
|
||||||
@@ -803,7 +803,7 @@ export default function CareerProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
|
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!isLocal || loading}
|
disabled={!isLocal || loading}
|
||||||
|
|||||||
@@ -701,8 +701,8 @@ function DocumentAiTab({ settings, outline, update }: {
|
|||||||
</Box>
|
</Box>
|
||||||
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
|
{!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 }}>
|
<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="en">{t("languageEnglish")}</MenuItem>
|
||||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{actions.map((action) => (
|
{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("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("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>
|
<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>
|
</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>}
|
{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";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { notificationDateLabel, UserNotification } from "../notifications";
|
import { notificationDateLabel, UserNotification } from "../notifications";
|
||||||
|
|
||||||
type Operation = {
|
type Operation = {
|
||||||
@@ -30,9 +31,9 @@ type Operation = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
||||||
const dateLabel = notificationDateLabel;
|
|
||||||
|
|
||||||
export default function OperationsPage() {
|
export default function OperationsPage() {
|
||||||
|
const { language, t } = useI18n();
|
||||||
const [operations, setOperations] = useState<Operation[]>([]);
|
const [operations, setOperations] = useState<Operation[]>([]);
|
||||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -50,11 +51,11 @@ export default function OperationsPage() {
|
|||||||
setNotifications(notificationResponse.data ?? []);
|
setNotifications(notificationResponse.data ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(getApiErrorMessage(requestError, "Operations could not be loaded."));
|
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -70,7 +71,7 @@ export default function OperationsPage() {
|
|||||||
await load();
|
await load();
|
||||||
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(getApiErrorMessage(requestError, "The action could not be completed."));
|
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null);
|
setBusyKey(null);
|
||||||
}
|
}
|
||||||
@@ -79,30 +80,30 @@ export default function OperationsPage() {
|
|||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
<Typography color="text.secondary">Background work survives navigation and refresh.</Typography>
|
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
|
||||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>Refresh</Button>
|
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
|
{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 } }}>
|
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>Notifications</Typography>
|
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
|
||||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">No notifications.</Typography> : null}
|
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
|
<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 sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
|
||||||
<Typography color="text.secondary">{notification.message}</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" }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||||
{!notification.readAtUtc ? (
|
{!notification.readAtUtc ? (
|
||||||
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
|
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
|
||||||
Mark read
|
{t("notificationsMarkRead")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
||||||
Dismiss
|
{t("notificationsDismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -111,8 +112,8 @@ export default function OperationsPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>Operations</Typography>
|
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
|
||||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">No background operations yet.</Typography> : null}
|
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
{operations.map((operation) => (
|
{operations.map((operation) => (
|
||||||
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
<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>
|
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
|
||||||
<Chip size="small" label={statusLabel(operation.status)} />
|
<Chip size="small" label={statusLabel(operation.status)} />
|
||||||
</Box>
|
</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.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.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 }}>Cancellation requested.</Typography> : null}
|
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>Failed: {statusLabel(operation.failureCategory)}</Alert> : 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" }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||||
{operation.canCancel ? (
|
{operation.canCancel ? (
|
||||||
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
|
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
|
||||||
Cancel
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{operation.canRetry ? (
|
{operation.canRetry ? (
|
||||||
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
|
<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>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</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
|
// Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent
|
||||||
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
|
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
|
||||||
function confidenceTone(confidence?: number) {
|
function confidenceTone(confidence?: number) {
|
||||||
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
if (typeof confidence !== "number") return { key: "profileCvConfidenceReview" as const, color: "default" as const };
|
||||||
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
if (confidence >= 0.8) return { key: "profileCvConfidenceHigh" as const, color: "success" as const };
|
||||||
if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const };
|
if (confidence >= 0.65) return { key: "profileCvConfidenceMedium" as const, color: "warning" as const };
|
||||||
return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const };
|
return { key: "profileCvConfidenceLow" as const, color: "error" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
|
export function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) {
|
||||||
|
const { t } = useI18n();
|
||||||
if (!metadata) return null;
|
if (!metadata) return null;
|
||||||
const tone = confidenceTone(metadata.confidence);
|
const tone = confidenceTone(metadata.confidence);
|
||||||
|
const label = typeof metadata.confidence === "number"
|
||||||
|
? t(tone.key, { percent: Math.round(metadata.confidence * 100) })
|
||||||
|
: t(tone.key);
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
<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.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
||||||
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
||||||
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : 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={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" } }} />
|
<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" }}>
|
<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 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="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 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>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
|||||||
import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder";
|
import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder";
|
||||||
import type { UserOperation } from "../../types";
|
import type { UserOperation } from "../../types";
|
||||||
import { useI18n } from "../../i18n/I18nProvider";
|
import { useI18n } from "../../i18n/I18nProvider";
|
||||||
|
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
|
||||||
|
|
||||||
export type CareerWorkspaceCompleteness = {
|
export type CareerWorkspaceCompleteness = {
|
||||||
percent: number;
|
percent: number;
|
||||||
@@ -123,7 +124,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
|||||||
</Box>
|
</Box>
|
||||||
<LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent ?? 0} sx={{ mt: 1, height: 8, borderRadius: 999 }} />
|
<LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent ?? 0} sx={{ mt: 1, height: 8, borderRadius: 999 }} />
|
||||||
{!loading && completeness?.missing.length ? (
|
{!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}
|
) : 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>
|
<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>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material";
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Chip, LinearProgress, Typography } from "@mui/material";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
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
|
// 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
|
// 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;
|
onRestore: (version: number) => void;
|
||||||
showSummary?: boolean;
|
showSummary?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
if ((!showSummary || !completeness) && versions.length <= 1) return null;
|
if ((!showSummary || !completeness) && versions.length <= 1) return null;
|
||||||
|
|
||||||
|
const locale = language === "nb" ? "nb-NO" : "en";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
{showSummary && completeness ? (
|
{showSummary && completeness ? (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
<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>
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
@@ -49,30 +54,30 @@ export default function ProfileCompleteness({
|
|||||||
/>
|
/>
|
||||||
{completeness.missing.length > 0 ? (
|
{completeness.missing.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
|
<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) => (
|
{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>
|
</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}
|
) : null}
|
||||||
{versions.length > 1 ? (
|
{versions.length > 1 ? (
|
||||||
<Accordion disableGutters elevation={0} sx={{ mt: showSummary ? 1.5 : 0, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
|
<Accordion disableGutters elevation={0} sx={{ mt: showSummary ? 1.5 : 0, "&:before": { display: "none" }, backgroundColor: "transparent" }}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, minHeight: 0 }}>
|
<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>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ px: 0, pt: 0 }}>
|
<AccordionDetails sx={{ px: 0, pt: 0 }}>
|
||||||
<Box sx={{ display: "grid", gap: 0.75 }}>
|
<Box sx={{ display: "grid", gap: 0.75 }}>
|
||||||
{versions.slice(0, 12).map((v) => (
|
{versions.slice(0, 12).map((v) => (
|
||||||
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
<Box key={v.version} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
<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>
|
</Typography>
|
||||||
{!v.isCurrent ? (
|
{!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}
|
) : null}
|
||||||
</Box>
|
</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