fix(cv): bind letter proposals to current sources and preserve document choices
This commit is contained in:
@@ -666,6 +666,8 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
|
||||
<CoverLetterAiAssistant
|
||||
jobId={jobId}
|
||||
documentLanguage={settings?.language}
|
||||
onDocumentLanguageChange={settings ? next => setSettingsDraft({ ...settings, language: next }) : undefined}
|
||||
toolbarHost={aiToolbarHost}
|
||||
currentText={text}
|
||||
disabled={busy || confirming || loading || !data}
|
||||
@@ -737,7 +739,7 @@ const COVER_LETTER_ACTIONS = [
|
||||
{ key: "tailor", labelKey: "coverActionTailor" },
|
||||
] as const;
|
||||
|
||||
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
|
||||
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost, documentLanguage, onDocumentLanguageChange }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null; documentLanguage?: "en" | "nb-NO"; onDocumentLanguageChange?: (language: "en" | "nb-NO") => void }) {
|
||||
const labelId = useId();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const { language: uiLanguage, t } = useI18n();
|
||||
@@ -746,11 +748,14 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
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<{ text: string; action: string } | null>(null);
|
||||
const [suggestion, setSuggestion] = useState<{ id: number; text: string; action: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<AssetError | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
useEffect(() => { if (documentLanguage) setLanguage(documentLanguage); }, [documentLanguage]);
|
||||
|
||||
const requestSequence = useRef(0);
|
||||
useEffect(() => {
|
||||
@@ -779,11 +784,39 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
});
|
||||
if (!isCurrent() || request !== requestSequence.current) return;
|
||||
const text = result.result.text?.trim() ?? "";
|
||||
setSuggestion(text ? { text, action: requestedAction } : null);
|
||||
setSuggestion(text ? { id: result.id, text, action: requestedAction } : null);
|
||||
} catch (err) {
|
||||
if (isCurrent() && request === requestSequence.current) {
|
||||
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
|
||||
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
|
||||
setError(sourceRequired || sourceChanged ? { kind: "error", message: "", messageKey: sourceChanged ? "coverAiSourceChanged" : "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const applySuggestion = async () => {
|
||||
if (!suggestion || busyRef.current || disabledRef.current || loadingCv) return;
|
||||
if (!Number.isSafeInteger(suggestion.id) || suggestion.id <= 0) {
|
||||
setSuggestion(null);
|
||||
setError({ kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false });
|
||||
return;
|
||||
}
|
||||
const request = ++requestSequence.current;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await aiWorkspaceApi.validateSource(jobId, suggestion.id);
|
||||
if (!isCurrent() || request !== requestSequence.current || disabledRef.current) return;
|
||||
onApply(suggestion.text, suggestion.action);
|
||||
setSuggestion(null);
|
||||
} catch (err) {
|
||||
if (isCurrent() && request === requestSequence.current) {
|
||||
setSuggestion(null);
|
||||
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
|
||||
setError(sourceChanged ? { kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
|
||||
@@ -835,7 +868,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||
<Select disabled={busy || disabled || loadingCv} 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) => { const next = event.target.value as "en" | "nb-NO"; setLanguage(next); onDocumentLanguageChange?.(next); }}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</Select>
|
||||
@@ -875,7 +908,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
|
||||
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
|
||||
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => void applySuggestion()}>{t("coverAiApply")}</Button>
|
||||
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace";
|
||||
import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder";
|
||||
@@ -25,6 +25,11 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
|
||||
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
|
||||
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("letterDocumentLanguage")} value={settings.language ?? "en"} disabled={disabled}
|
||||
onChange={event => onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
{detailFields.map(key => <TextField key={key} label={t(fieldLabels[key])} size="small" fullWidth multiline={key === "recipient"} value={settings[key]} disabled={disabled}
|
||||
slotProps={{ htmlInput: { maxLength: key === "recipient" ? 2000 : key === "subject" ? 500 : key === "date" || key === "senderPhone" ? 100 : 300 } }}
|
||||
@@ -44,6 +49,7 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedContact, setSelectedContact] = useState<readonly typeof contactFields[number][]>(contactFields.filter(key => key !== "senderLocation"));
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setLoading(true); setFailed(false);
|
||||
@@ -78,25 +84,37 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
// The dialog captures the original local draft until the user explicitly applies or cancels.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected, kind, attempt]);
|
||||
const apply = () => {
|
||||
if (!snapshot) return;
|
||||
if (kind === "design") { onApply(snapshot); return; }
|
||||
const next = { ...settings };
|
||||
const chosen = selectedContact.filter(key => snapshot[key].trim());
|
||||
for (const key of chosen) next[key] = snapshot[key];
|
||||
if (chosen.length) next.sourceCvId = snapshot.sourceCvId;
|
||||
onApply(next);
|
||||
};
|
||||
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
|
||||
<DialogTitle id={`${id}-title`}>{t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<Typography id={`${id}-help`} variant="body2">{t("letterCopyHelp")}</Typography>
|
||||
{kind === "contact" && <Typography variant="body2">{t("letterCopyContactHelp")}</Typography>}
|
||||
<TextField select fullWidth size="small" label={t("letterSourceCv")} value={selected} disabled={loading} onChange={event => setSelected(Number(event.target.value))}>
|
||||
{variants.map(variant => <MenuItem key={variant.id} value={variant.id}>{variant.name}</MenuItem>)}
|
||||
</TextField>
|
||||
{!loading && !failed && variants.length === 0 && <Typography variant="body2">{t("assetsNoCvVariants")}</Typography>}
|
||||
{failed && <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterCopyFailed")}</Alert>}
|
||||
{snapshot && <Box role="region" aria-label={t(kind === "contact" ? "letterContactSnapshot" : "letterDesignSnapshot")} sx={{ overflowWrap: "anywhere" }}>
|
||||
{kind === "contact" ? contactFields.map(key => <Typography key={key} variant="body2">{t(fieldLabels[key])}: {snapshot[key]}</Typography>) : <>
|
||||
{kind === "contact" ? <Stack>{contactFields.map(key => <FormControlLabel key={key} label={`${t(fieldLabels[key])}: ${snapshot[key]}`}
|
||||
control={<Checkbox size="small" disabled={!snapshot[key].trim()} checked={selectedContact.includes(key) && !!snapshot[key].trim()}
|
||||
onChange={(_, checked) => setSelectedContact(current => checked ? [...current, key] : current.filter(field => field !== key))} />} />)}</Stack> : <>
|
||||
<Typography variant="body2">{snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont}</Typography>
|
||||
<Typography variant="body2">{snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize}</Typography>
|
||||
</>}
|
||||
</Box>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={() => snapshot && onApply(snapshot)}>{t("letterCopyApply")}</Button></DialogActions>
|
||||
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={apply}>{t("letterCopyApply")}</Button></DialogActions>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user