fix(ui): preserve application drafts and recovery states

This commit is contained in:
cesnimda
2026-10-13 23:27:16 +02:00
parent 627955afa9
commit 4c3254c472
19 changed files with 5388 additions and 118 deletions
@@ -19,6 +19,9 @@ 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";
@@ -30,52 +33,75 @@ import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../
// 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<void> };
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: getApiErrorMessage(error, ""), messageKey, retryable: retryable && !unauthorized, status };
}
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
const { t } = useI18n();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(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(() => {
let cancelled = false;
const reload = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = ++request.current;
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]);
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(() => reload(), [reload]);
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 };
return { data, error, loading, setData, setError, reload, isCurrent: () => active.current };
}
function Shell({ title, subtitle, loading, error, children }: {
function Shell({ title, subtitle, loading, error, available = true, onRetry, contentRef, children }: {
title: string;
subtitle?: string;
loading: boolean;
error: string | null;
error: AssetError | null;
available?: boolean;
onRetry?: () => void | Promise<void>;
contentRef?: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode;
}) {
const { t } = useI18n();
const localRef = useRef<HTMLDivElement>(null);
const bodyRef = contentRef ?? localRef;
const retry = error?.retry ?? onRetry;
return (
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
<Box ref={bodyRef} role="group" aria-label={title} tabIndex={-1} sx={{ minWidth: 0 }}>
<ViewStateNotice title={title} loading={loading} error={error ? { ...error, message: error.message || t(error.messageKey) } : null}
onRetry={retry ? () => { bodyRef.current?.focus(); return retry(); } : undefined} compact />
{available && error?.kind !== "unauthorized" && children}
</Box>
</PageSection>
);
}
@@ -84,20 +110,29 @@ function Shell({ title, subtitle, loading, error, children }: {
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const [createdCopy, setCreatedCopy] = useState<number | null>(null);
const attach = async (variantId: number | null) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
const next = await applicationAssetsApi.attachVariant(jobId, variantId);
if (!isCurrent()) return;
setData(next);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsAttachCvFailed")));
if (isCurrent()) setError(assetError(err, "assetsAttachCvFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -105,15 +140,24 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
const attached = data?.attachedVariantId ?? "";
const duplicateForJob = async () => {
if (!data?.attachedVariantId) return;
if (busyRef.current || loading || !data?.attachedVariantId) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
let copyId = createdCopy;
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}`);
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) {
setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed")));
if (isCurrent()) setError(assetError(err, copyId ? "assetsAttachCvFailed" : "assetsDuplicateCvFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -123,8 +167,11 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
<Shell
title={t("workspaceCv")}
subtitle={t("assetsCvSubtitle")}
loading={loading}
loading={loading || busy}
error={error}
available={data !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? (
@@ -138,7 +185,7 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
fullWidth
label={t("assetsAttachedCv")}
value={attached}
disabled={busy}
disabled={busy || loading}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText={t("assetsAttachedCvHelp")}
>
@@ -172,8 +219,8 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
>
{t("assetsEditPreviewExport")}
</Button>
<Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}>
{t("assetsDuplicateForJob")}
<Button size="small" variant="contained" disabled={busy || loading} onClick={() => void duplicateForJob()}>
{t(createdCopy ? "assetsAttachCreatedCopy" : "assetsDuplicateForJob")}
</Button>
</Stack>
</Stack>
@@ -189,6 +236,7 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
{t("assetsLegacyCvText")}
</Alert>
)}
{createdCopy && <Alert severity="info">{t("assetsCopyCreatedHelp")}</Alert>}
</Stack>
</Shell>
@@ -202,49 +250,73 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
const titleId = useId();
const viewTrigger = useRef<HTMLButtonElement | null>(null);
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const [detail, setDetail] = useState<SubmittedPackageDetail | null>(null);
const [detailBusy, setDetailBusy] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
const operation = useRef(false);
const contentRef = useRef<HTMLDivElement>(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) {
setError(getApiErrorMessage(err, t("assetsPackageCaptureFailed")));
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) {
setError(getApiErrorMessage(err, t("assetsPackageDownloadFailed")));
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 {
setDetail(await applicationAssetsApi.submittedPackage(jobId, item.id));
const next = await applicationAssetsApi.submittedPackage(jobId, item.id);
if (!isCurrent()) return;
setDetail(next);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageLoadFailed")));
if (isCurrent()) setError({ ...assetError(err, "assetsPackageLoadFailed", true), retry: () => view(item) });
} finally {
operation.current = false;
setDetailBusy(false);
}
};
@@ -255,9 +327,9 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
})();
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={pending} error={error} available={data !== null} onRetry={reload} contentRef={contentRef}>
<Stack spacing={1.5}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={pending} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>}
{(data?.length ?? 0) === 0 ? (
@@ -277,11 +349,11 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
<Button size="small" variant="outlined" disabled={pending} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
<Button key={attachment.id} size="small" variant="text" disabled={pending} startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
@@ -293,25 +365,25 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
<DialogTitle id={titleId}>
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<DialogContent dividers role="region" tabIndex={0} aria-labelledby={titleId}>
<Alert severity="info" sx={{ mb: 2 }}>{t("workspaceFrozenHelp")}</Alert>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography component="h3" variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, overflowWrap: "anywhere" }}>{detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography component="h3" variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography component="h3" variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
@@ -319,7 +391,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
[t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)],
[t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)],
].map(([label, content]) => content ? <Box key={label}>
<Typography variant="subtitle2">{label}</Typography>
<Typography component="h3" variant="subtitle2">{label}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{content}</Typography>
</Box> : null)}
</Stack>
@@ -334,7 +406,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useAsset<TailoringPlan>(
const { data, error, loading, reload } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId),
[jobId],
);
@@ -345,6 +417,8 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
subtitle={t("assetsTailoringSubtitle")}
loading={loading}
error={error}
available={data !== null}
onRetry={reload}
>
<Stack spacing={2}>
{data && !data.hasCareerProfile && (
@@ -413,13 +487,17 @@ Med vennlig hilsen
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
const { confirm } = useConfirm();
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
@@ -432,30 +510,53 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual", aiAction?: string) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction));
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction);
if (!isCurrent()) return;
setData(next);
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverSaveFailed")));
if (isCurrent()) setError(assetError(err, "assetsCoverSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
const restore = async (version: number) => {
setBusy(true);
if (busyRef.current || loading || !data) return;
busyRef.current = true;
try {
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
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);
if (!isCurrent()) return;
setData(next);
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed")));
if (isCurrent()) setError(assetError(err, "assetsCoverRestoreFailed"));
} finally {
busyRef.current = false;
setBusy(false);
setConfirming(false);
}
};
@@ -464,28 +565,31 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<Shell
title={t("workspaceCoverLetter")}
subtitle={t("assetsCoverSubtitle")}
loading={loading}
loading={loading || busy}
error={error}
available={data !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy}
disabled={busy || confirming || loading}
onChange={setDraft}
placeholder={t("assetsCoverPlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
<Button variant="contained" disabled={busy || confirming || loading || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
{t("save")}
</Button>
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
{t("assetsDiscardChanges")}
</Button>
<Button
disabled={busy || text.trim().length > 0}
disabled={busy || confirming || loading || text.trim().length > 0}
onClick={() => setDraft(COVER_LETTER_TEMPLATES[language])}
>
{t("assetsStartTemplate")}
@@ -500,10 +604,11 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
currentText={text}
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
disabled={busy || confirming || loading || !data}
onApply={(value, aiAction) => { if (!busyRef.current && !loading && data) { setDraft(value); setDraftAiAction(aiAction); } }}
/>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null}>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null} available={data !== null}>
{(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("assetsNoVersions")}
@@ -529,11 +634,11 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
</Typography>
</Box>
{!v.isCurrent && (
<Tooltip title={t("assetsRestoreVersion")}>
<Tooltip describeChild title={t("assetsRestoreVersion")}>
<span>
<IconButton
size="small"
disabled={busy}
disabled={busy || loading}
aria-label={t("assetsRestoreVersionNumber", { version: v.version })}
onClick={() => restore(v.version)}
>
@@ -563,20 +668,25 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
const { data: cv, error: cvError, loading: loadingCv, reload, isCurrent } = useAsset<ApplicationCv>(() => 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 [suggestion, setSuggestion] = useState<{ text: string; action: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const generate = async () => {
if (busyRef.current || disabled || loadingCv || !canUseAi || !cv?.attachedVariantId) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
setError(null);
try {
@@ -588,10 +698,13 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
currentText: currentText.trim() || undefined,
extraContext: instructions.trim() || undefined,
});
setSuggestion(result.result.text?.trim() ?? "");
if (!isCurrent()) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action } : null);
} catch (err) {
setError(getApiErrorMessage(err, t("coverAiGenerateFailed")));
if (isCurrent()) setError(assetError(err, "coverAiGenerateFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -601,8 +714,11 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Shell
title={t("coverAiTitle")}
subtitle={t("coverAiSubtitle")}
loading={loadingCv}
error={null}
loading={loadingCv || busy}
error={cvError}
available={cv !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
{!hasCv ? (
@@ -615,13 +731,13 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
<Select labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
<Select labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
@@ -630,7 +746,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -640,16 +756,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
label={t("coverAiAdditionalInstructions")}
placeholder={t("coverAiInstructionsPlaceholder")}
value={instructions}
disabled={busy || disabled || loadingCv}
onChange={(event) => setInstructions(event.target.value)}
multiline
minRows={2}
fullWidth
/>
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>{t("retry")}</Button>}>{error}</Alert>}
{error && <Alert severity="error" action={<Button color="inherit" size="small" disabled={busy || disabled || loadingCv} onClick={() => void generate()}>{t("retry")}</Button>}>{error.message || t(error.messageKey)}</Alert>}
<Button
variant="contained"
startIcon={<AutoFixHighIcon />}
disabled={!canUseAi || !hasCv || busy}
disabled={!canUseAi || !hasCv || busy || disabled || loadingCv}
onClick={() => void generate()}
sx={{ alignSelf: "flex-start" }}
>
@@ -666,10 +783,10 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion.text}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>{t("coverAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
</Box>
@@ -701,7 +818,11 @@ export function ApplicationPackageDraftsSection({
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
if (draft === null) setSaved(initial);
@@ -722,16 +843,21 @@ export function ApplicationPackageDraftsSection({
const update = (patch: Partial<PackageDrafts>) => setDraft({ ...value, ...patch });
const save = async () => {
if (busyRef.current) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
if (!active.current) return;
setSaved(value);
setDraft(null);
setError(null);
onSaved?.();
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed")));
if (active.current) setError(assetError(err, "assetsDraftsSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -740,8 +866,9 @@ export function ApplicationPackageDraftsSection({
<Shell
title={t("assetsApplicationDrafts")}
subtitle={t("assetsApplicationDraftsSubtitle")}
loading={false}
loading={busy}
error={error}
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField