fix(ui): preserve application drafts and recovery states
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user