import React, { useCallback, useEffect, useId, useRef, useState } from "react"; import { Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Stack, TextField, Tooltip, Typography, } from "@mui/material"; import RichTextField from "./RichTextField"; 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, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi, } from "../applicationWorkspace"; import { cvBuilderApi } from "../cvBuilder"; import { aiWorkspaceApi } from "../aiWorkspace"; import { useAccountPlan } from "../accountPlan"; import { useI18n } from "../i18n/I18nProvider"; 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. function useAsset(load: () => Promise, deps: React.DependencyList) { const { t } = useI18n(); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); // eslint-disable-next-line react-hooks/exhaustive-deps const run = useCallback(load, deps); const reload = useCallback(() => { let cancelled = false; setLoading(true); run() .then((r) => { if (!cancelled) { setData(r); setError(null); } }) .catch((err) => { if (!cancelled) setError(getApiErrorMessage(err, t("assetsLoadFailed"))); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [run, t]); useEffect(() => reload(), [reload]); return { data, error, loading, setData, setError, reload }; } function Shell({ title, subtitle, loading, error, children }: { title: string; subtitle?: string; loading: boolean; error: string | null; children: React.ReactNode; }) { return ( {!loading && !error && children} ); } // ---------- CV ---------- export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) { const { t } = useI18n(); const { data, error, loading, setData, setError } = useAsset( () => applicationAssetsApi.cv(jobId), [jobId], ); const [busy, setBusy] = useState(false); const attach = async (variantId: number | null) => { setBusy(true); try { setData(await applicationAssetsApi.attachVariant(jobId, variantId)); setError(null); } catch (err) { setError(getApiErrorMessage(err, t("assetsAttachCvFailed"))); } finally { setBusy(false); } }; const attached = data?.attachedVariantId ?? ""; const duplicateForJob = async () => { if (!data?.attachedVariantId) return; setBusy(true); try { const copy = await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" })); setData(await applicationAssetsApi.attachVariant(jobId, copy.id)); window.location.assign(`/career/builder/${copy.id}`); } catch (err) { setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed"))); } finally { 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")} )} {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 } = useAsset( () => applicationAssetsApi.submittedPackages(jobId), [jobId], ); const [busy, setBusy] = useState(false); const [detail, setDetail] = useState(null); const [detailBusy, setDetailBusy] = useState(false); const capture = async () => { setBusy(true); try { const created = await applicationAssetsApi.captureSubmittedPackage(jobId); setData([created, ...(data ?? [])]); setError(null); } catch (err) { setError(getApiErrorMessage(err, t("assetsPackageCaptureFailed"))); } finally { setBusy(false); } }; const download = async (item: SubmittedPackage, attachment: SubmittedPackage["attachments"][number]) => { try { const blob = await applicationAssetsApi.downloadSubmittedAttachment(jobId, item.id, attachment.id); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = attachment.fileName; anchor.click(); URL.revokeObjectURL(url); } catch (err) { setError(getApiErrorMessage(err, t("assetsPackageDownloadFailed"))); } }; const view = async (item: SubmittedPackage) => { setDetailBusy(true); try { setDetail(await applicationAssetsApi.submittedPackage(jobId, item.id)); setError(null); } catch (err) { setError(getApiErrorMessage(err, t("assetsPackageLoadFailed"))); } finally { 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 ---------- export function ApplicationTailoringSection({ jobId }: { jobId: number }) { const { t } = useI18n(); const { data, error, loading } = 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) => ( {s.title} {s.detail && ( {s.detail} )} {s.items.map((item) => ( ))} )) )} ); } // ---------- Cover letter ---------- 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 { language, t } = useI18n(); const { data, error, loading, setData, setError } = useAsset( () => applicationAssetsApi.coverLetter(jobId), [jobId], ); const [draft, setDraft] = useState(null); const [draftAiAction, setDraftAiAction] = useState(null); const [busy, setBusy] = useState(false); // 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 dirty = draft !== null && draft !== (data?.text ?? ""); useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); const save = async (value: string, source = "manual", aiAction?: string) => { setBusy(true); try { setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction)); setDraft(null); setDraftAiAction(null); setError(null); } catch (err) { setError(getApiErrorMessage(err, t("assetsCoverSaveFailed"))); } finally { setBusy(false); } }; const restore = async (version: number) => { setBusy(true); try { setData(await applicationAssetsApi.restoreCoverLetter(jobId, version)); setDraft(null); setDraftAiAction(null); setError(null); } catch (err) { setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed"))); } finally { setBusy(false); } }; return ( {dirty && ( )} { setDraft(value); setDraftAiAction(aiAction); }} /> {(data?.versions.length ?? 0) === 0 ? ( {t("assetsNoVersions")} ) : ( {(data?.versions ?? []).map((v) => ( v{v.version} {v.isCurrent && } {new Date(v.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} · {t("assetsCharacterCount", { count: v.length })} {!v.isCurrent && ( restore(v.version)} > )} ))} )} ); } const COVER_LETTER_ACTIONS = [ { key: "generate", labelKey: "coverActionGenerate" }, { key: "regenerate", labelKey: "coverActionRegenerate" }, { key: "improve", labelKey: "coverActionImprove" }, { key: "shorten", labelKey: "coverActionShorten" }, { key: "expand", labelKey: "coverActionExpand" }, { key: "professional", labelKey: "coverActionProfessional" }, { key: "natural", labelKey: "coverActionNatural" }, { key: "grammar", labelKey: "coverActionGrammar" }, { key: "tailor", labelKey: "coverActionTailor" }, ] as const; function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) { const labelId = useId(); const { canUseAi } = useAccountPlan(); const { language: uiLanguage, t } = useI18n(); const { data: cv, loading: loadingCv } = useAsset(() => applicationAssetsApi.cv(jobId), [jobId]); const [action, setAction] = useState("generate"); const [mode, setMode] = useState("professional"); const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en"); const [instructions, setInstructions] = useState(""); const [suggestion, setSuggestion] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const generate = async () => { setBusy(true); setError(null); try { const result = await aiWorkspaceApi.generate(jobId, { module: "cover-letter", mode, action, targetLanguage: language, currentText: currentText.trim() || undefined, extraContext: instructions.trim() || undefined, }); setSuggestion(result.result.text?.trim() ?? ""); } catch (err) { setError(getApiErrorMessage(err, t("coverAiGenerateFailed"))); } finally { setBusy(false); } }; const hasCv = !!cv?.attachedVariantId; return ( {!hasCv ? ( {t("coverAiSelectCv")} ) : ( {t("coverAiUsing", { name: cv?.attachedVariantName ?? "" })} )} {t("coverAiAction")} {t("coverAiTone")} {t("coverAiDocumentLanguage")} setInstructions(event.target.value)} multiline minRows={2} fullWidth /> {error && void generate()}>{t("retry")}}>{error}} {suggestion && ( {t("coverAiCurrent")} {currentText || t("coverAiNoDraft")} {t("coverAiSuggestion")} {suggestion} )} ); } // ---------- Application answer and recruiter message ---------- type PackageDrafts = { applicationAnswer: string; recruiterMessage: string }; export function ApplicationPackageDraftsSection({ jobId, initialApplicationAnswer, initialRecruiterMessage, onSaved, onDirtyChange, }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; onSaved?: () => void; onDirtyChange?: (dirty: boolean) => void; }) { const { t } = useI18n(); const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage }; const [saved, setSaved] = useState(initial); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (draft === null) setSaved(initial); // `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobId, initialApplicationAnswer, initialRecruiterMessage]); const value = draft ?? saved; const dirty = draft !== null && ( draft.applicationAnswer !== saved.applicationAnswer || draft.recruiterMessage !== saved.recruiterMessage ); useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); const update = (patch: Partial) => setDraft({ ...value, ...patch }); const save = async () => { setBusy(true); try { await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage); setSaved(value); setDraft(null); setError(null); onSaved?.(); } catch (err) { setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed"))); } finally { setBusy(false); } }; return ( update({ applicationAnswer })} placeholder={t("assetsApplicationAnswerPlaceholder")} /> update({ recruiterMessage })} placeholder={t("assetsRecruiterMessagePlaceholder")} /> {dirty && } ); }