import React, { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme, } from "@mui/material"; import RichTextField from "./RichTextField"; import Markdown from "./Markdown"; import { CoverLetterDocumentFields, CoverLetterDocumentPreview } from "./CoverLetterDocument"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import RestoreIcon from "@mui/icons-material/Restore"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import ArchiveOutlinedIcon from "@mui/icons-material/ArchiveOutlined"; import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined"; import { getApiErrorMessage } from "../api"; import { ApplicationCv, CoverLetter, CoverLetterDocumentSettings, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi, } from "../applicationWorkspace"; import { cvBuilderApi } from "../cvBuilder"; import { aiWorkspaceApi } from "../aiWorkspace"; import { useAccountPlan } from "../accountPlan"; import { useI18n } from "../i18n/I18nProvider"; import type { TranslationKey } from "../i18n/translations"; import type { ViewResourceError } from "../hooks/useViewResource"; import { useConfirm } from "../confirm"; import { PageSection } from "../layout/PageLayout"; import ViewStateNotice from "./ViewStateNotice"; import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../applicationDrafts"; // Phase 5.4 — Application Assets sections for the workspace. // // These compose systems that already exist. CV editing, preview, PDF export, themes and version // history all live in the CV builder at /cv-builder — this section only chooses WHICH variant the // application uses and links out. The cover letter is the one thing genuinely owned here, because it // is application-specific by nature. docs/architecture/overview.md. type AssetError = ViewResourceError & { messageKey: TranslationKey; retry?: () => Promise }; function assetError(error: unknown, messageKey: TranslationKey, retryable = false): AssetError { const status = (error as { response?: { status?: number } })?.response?.status; const unauthorized = status === 401 || status === 403; return { kind: unauthorized ? "unauthorized" : "error", message: status === 409 ? "" : getApiErrorMessage(error, ""), messageKey: status === 409 ? "revisionConflict" : messageKey, retryable: retryable && !unauthorized, status }; } function useAsset(load: () => Promise, deps: React.DependencyList) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const request = useRef(0); const pending = useRef(false); const active = useRef(true); // eslint-disable-next-line react-hooks/exhaustive-deps const run = useCallback(load, deps); const reload = useCallback(async () => { if (pending.current) return; pending.current = true; const current = ++request.current; setLoading(true); try { const next = await run(); if (current !== request.current) return; setData(next); setError(null); } catch (err) { if (current === request.current) setError(assetError(err, "assetsLoadFailed", true)); } finally { if (current === request.current) { pending.current = false; setLoading(false); } } }, [run]); useEffect(() => { active.current = true; setData(null); setError(null); pending.current = false; void reload(); return () => { active.current = false; request.current += 1; pending.current = false; }; }, [reload]); return { data, error, loading, setData, setError, reload, isCurrent: () => active.current }; } function Shell({ title, subtitle, loading, error, available = true, onRetry, contentRef, children }: { title: string; subtitle?: string; loading: boolean; error: AssetError | null; available?: boolean; onRetry?: () => void | Promise; contentRef?: React.RefObject; children: React.ReactNode; }) { const { t } = useI18n(); const localRef = useRef(null); const bodyRef = contentRef ?? localRef; const retry = error?.retry ?? onRetry; return ( { bodyRef.current?.focus(); return retry(); } : undefined} compact /> {available && error?.kind !== "unauthorized" && children} ); } // ---------- CV ---------- export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) { const { t } = useI18n(); const { data, error, loading, setData, setError, reload, isCurrent } = useAsset( () => applicationAssetsApi.cv(jobId), [jobId], ); const [busy, setBusy] = useState(false); const busyRef = useRef(false); const contentRef = useRef(null); const [createdCopy, setCreatedCopy] = useState(null); const attach = async (variantId: number | null) => { if (busyRef.current || loading || !data) return; busyRef.current = true; contentRef.current?.focus(); setBusy(true); try { const next = await applicationAssetsApi.attachVariant(jobId, variantId); if (!isCurrent()) return; setData(next); setError(null); } catch (err) { if (isCurrent()) setError(assetError(err, "assetsAttachCvFailed")); } finally { busyRef.current = false; setBusy(false); } }; const attached = data?.attachedVariantId ?? ""; const duplicateForJob = async () => { if (busyRef.current || loading || !data?.attachedVariantId) return; busyRef.current = true; contentRef.current?.focus(); setBusy(true); let copyId = createdCopy; try { copyId ??= (await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }))).id; if (!isCurrent()) return; setCreatedCopy(copyId); const next = await applicationAssetsApi.attachVariant(jobId, copyId); if (!isCurrent()) return; setData(next); setCreatedCopy(null); window.location.assign(`/career/builder/${copyId}`); } catch (err) { if (isCurrent()) setError(assetError(err, copyId ? "assetsAttachCvFailed" : "assetsDuplicateCvFailed")); } finally { busyRef.current = false; setBusy(false); } }; return ( {(data?.availableVariants.length ?? 0) === 0 ? ( {t("assetsNoCvVariants")} ) : ( attach(e.target.value === "" ? null : Number(e.target.value))} helperText={t("assetsAttachedCvHelp")} > {t("assetsNone")} {(data?.availableVariants ?? []).map((v) => ( {v.name} · {v.themeId} · v{v.version} ))} )} {data?.attachedVariantId ? ( {data.attachedVariantName} {t("assetsThemeVersion", { theme: data.attachedThemeId ?? "—", version: data.attachedVersion ?? "—" })} {data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""} ) : ( {t("assetsNoCvAttached")} )} {data?.hasTailoredCvText && ( {t("assetsLegacyCvText")} )} {createdCopy && {t("assetsCopyCreatedHelp")}} {includePackages && } ); } export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId: number; allowCapture?: boolean }) { const titleId = useId(); const viewTrigger = useRef(null); const { language, t } = useI18n(); const { data, error, loading, setData, setError, reload, isCurrent } = useAsset( () => applicationAssetsApi.submittedPackages(jobId), [jobId], ); const [busy, setBusy] = useState(false); const [detail, setDetail] = useState(null); const [detailBusy, setDetailBusy] = useState(false); const [downloadBusy, setDownloadBusy] = useState(false); const operation = useRef(false); const contentRef = useRef(null); const pending = busy || detailBusy || downloadBusy || loading; const capture = async () => { if (operation.current || loading || !data) return; operation.current = true; contentRef.current?.focus(); setBusy(true); try { const created = await applicationAssetsApi.captureSubmittedPackage(jobId); if (!isCurrent()) return; setData([created, ...(data ?? [])]); setError(null); } catch (err) { if (isCurrent()) setError(assetError(err, "assetsPackageCaptureFailed")); } finally { operation.current = false; setBusy(false); } }; const download = async (item: SubmittedPackage, attachment: SubmittedPackage["attachments"][number]) => { if (operation.current || loading || !data) return; operation.current = true; contentRef.current?.focus(); setDownloadBusy(true); try { const blob = await applicationAssetsApi.downloadSubmittedAttachment(jobId, item.id, attachment.id); if (!isCurrent()) return; const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = attachment.fileName; anchor.click(); URL.revokeObjectURL(url); setError(null); } catch (err) { if (isCurrent()) setError({ ...assetError(err, "assetsPackageDownloadFailed", true), retry: () => download(item, attachment) }); } finally { operation.current = false; setDownloadBusy(false); } }; const view = async (item: SubmittedPackage) => { if (operation.current || loading || !data) return; operation.current = true; contentRef.current?.focus(); setDetailBusy(true); try { const next = await applicationAssetsApi.submittedPackage(jobId, item.id); if (!isCurrent()) return; setDetail(next); setError(null); } catch (err) { if (isCurrent()) setError({ ...assetError(err, "assetsPackageLoadFailed", true), retry: () => view(item) }); } finally { operation.current = false; setDetailBusy(false); } }; const material = (() => { if (!detail) return null; try { return JSON.parse(detail.applicationMaterialJson) as Record; } catch { return null; } })(); return ( {allowCapture && } {(data?.length ?? 0) === 0 ? ( {t("assetsNoSubmittedPackages")} ) : data?.map((item) => ( {t("assetsSubmittedPackageVersion", { version: item.version })} {new Date(item.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} {item.attachments.map((attachment) => ( ))} ))} setDetail(null)} fullWidth maxWidth="lg" aria-labelledby={titleId} slotProps={{ transition: { onExited: () => viewTrigger.current?.focus() } }}> {detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")} {t("workspaceFrozenHelp")} {detail?.renderedCvHtml ? ( {t("workspaceCv")} {detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })} ) : {t("assetsPackageNoCv")}} {t("workspaceCoverLetter")} {detail?.coverLetterText || t("assetsPackageNoCoverLetter")} {material && ["TailoredCvText", "RecruiterMessageDraft"].map((key) => typeof material[key] === "string" && material[key] ? ( {t(`assetsPackage${key}` as "assetsPackageTailoredCvText")} {String(material[key])} ) : null)} {material && typeof material.Notes === "string" && [ [t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)], [t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)], ].map(([label, content]) => content ? {label} {content} : null)} ); } // ---------- Tailoring ---------- const tailoringLabels = new Map([ ["highlight-skills", ["assetsTailoringHighlightTitle", "assetsTailoringHighlightDetail"]], ["prioritise-experience", ["assetsTailoringExperienceTitle", "assetsTailoringExperienceDetail"]], ["emphasise-projects", ["assetsTailoringProjectsTitle", "assetsTailoringProjectsDetail"]], ["career-evidence", ["assetsTailoringEvidenceTitle", "assetsTailoringEvidenceDetail"]], ["include-keywords", ["assetsTailoringKeywordsTitle", "assetsTailoringKeywordsDetail"]], ["gaps", ["assetsTailoringGapsTitle", "assetsTailoringGapsDetail"]], ]); export function ApplicationTailoringSection({ jobId }: { jobId: number }) { const { t } = useI18n(); const { data, error, loading, reload } = useAsset( () => applicationAssetsApi.tailoring(jobId), [jobId], ); return ( {data && !data.hasCareerProfile && ( {t("assetsBuildProfile")} )} {data && !data.hasJobDescription && ( {t("assetsAddAdvert")} )} {(data?.suggestions.length ?? 0) === 0 ? ( {t("assetsNoSuggestions")} ) : ( (data?.suggestions ?? []).map((s) => { const labels = tailoringLabels.get(s.kind); return ( {labels ? t(labels[0]) : s.title} {s.detail && ( {labels ? t(labels[1]) : s.detail} )} {s.items.map((item) => ( ))} ); }) )} ); } // ---------- Cover letter ---------- const historySources = new Map([ ["legacy", "historySourceLegacy"], ["manual", "historySourceManual"], ["ai", "historySourceAi"], ["template", "historySourceTemplate"], ["restore", "historySourceRestore"], ]); const historyAiActions = new Map([ ["generate", "coverActionGenerate"], ["regenerate", "coverActionRegenerate"], ["improve", "coverActionImprove"], ["shorten", "coverActionShorten"], ["expand", "coverActionExpand"], ["professional", "coverActionProfessional"], ["natural", "coverActionNatural"], ["grammar", "coverActionGrammar"], ["tone", "coverAiTone"], ["tailor", "coverActionTailor"], ]); const COVER_LETTER_TEMPLATES = { en: `Dear Hiring Manager, I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.] In my current role I [the most relevant thing you have done, with a concrete outcome]. [A second example that matches what the advert asks for.] [Why you want this job, in your own words.] I would welcome the chance to talk it through. Kind regards, [Your name]`, nb: `Kjære rekrutteringsansvarlig, Jeg søker med dette på stillingen som [stilling] hos [selskap]. [Én konkret setning om hvorfor akkurat dette selskapet.] I min nåværende rolle har jeg [det mest relevante du har gjort, med et konkret resultat]. [Et annet eksempel som samsvarer med det stillingsannonsen etterspør.] [Hvorfor du ønsker denne stillingen, med egne ord.] Jeg ser frem til muligheten til å utdype dette i et intervju. Med vennlig hilsen [Navnet ditt]`, } as const; export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) { const [aiToolbarHost, setAiToolbarHost] = useState(null); const { language, t } = useI18n(); const workspaceId = useId(); const wide = useMediaQuery(useTheme().breakpoints.up("md")); const [pane, setPane] = useState("write"); const { confirm } = useConfirm(); const { data, error, loading, setData, setError, reload, isCurrent } = useAsset( () => applicationAssetsApi.coverLetter(jobId), [jobId], ); const [draft, setDraft] = useState(null); const [settingsDraft, setSettingsDraft] = useState(null); const [draftAiAction, setDraftAiAction] = useState(null); const [busy, setBusy] = useState(false); const [confirming, setConfirming] = useState(false); const busyRef = useRef(false); const contentRef = useRef(null); // The textarea is only seeded from the server until the user starts typing, so a reload never // clobbers unsaved edits. const text = draft ?? data?.text ?? ""; const settings = settingsDraft ?? data?.documentSettings; const settingsDirty = settingsDraft !== null && JSON.stringify(settingsDraft) !== JSON.stringify(data?.documentSettings); const dirty = (draft !== null && draft !== (data?.text ?? "")) || settingsDirty; useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); const save = async (value: string, source = "manual", aiAction?: string, exportPdf = false) => { if (busyRef.current || loading || !data) return; busyRef.current = true; contentRef.current?.focus(); setBusy(true); try { const next = !exportPdf || dirty ? await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction, settingsDirty ? settings : undefined) : data; if (!isCurrent()) return; setData(next); setDraft(null); setSettingsDraft(null); setDraftAiAction(null); setError(null); if (exportPdf) { if (!Number.isInteger(next.revision) || next.revision <= 0) throw new Error("Missing committed job revision"); const blob = await applicationAssetsApi.exportCoverLetter(jobId, next.revision); if (!isCurrent()) return; const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = "cover-letter.pdf"; anchor.click(); URL.revokeObjectURL(url); } } catch (err) { if (isCurrent()) setError(assetError(err, exportPdf ? "letterExportFailed" : "assetsCoverSaveFailed")); } finally { busyRef.current = false; setBusy(false); } }; const restore = async (version: number) => { if (busyRef.current || loading || !data) return; busyRef.current = true; try { if (dirty) { setConfirming(true); const accepted = await confirm({ title: t("assetsRestoreDiscardTitle"), message: t("assetsRestoreDiscardMessage"), confirmLabel: t("assetsRestoreConfirm"), cancelLabel: t("cancel"), destructive: true, }); setConfirming(false); if (!accepted) return; } if (!isCurrent()) return; contentRef.current?.focus(); setBusy(true); const next = await applicationAssetsApi.restoreCoverLetter(jobId, version, data.revision); if (!isCurrent()) return; setData(next); setDraft(null); setSettingsDraft(null); setDraftAiAction(null); setError(null); } catch (err) { if (isCurrent()) setError(assetError(err, "assetsCoverRestoreFailed")); } finally { busyRef.current = false; setBusy(false); setConfirming(false); } }; return ( {!wide && setPane(value)} aria-label={t("assetsLetterWorkspace")} variant="fullWidth"> }