feat(cv-qa): map missing records from reviewed source evidence

This commit is contained in:
cesnimda
2026-10-26 19:43:30 +01:00
parent 249d06848a
commit 5e68848cf7
13 changed files with 778 additions and 14 deletions
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { I18nProvider } from './i18n/I18nProvider';
import CvImportMappingEditor, { type MappingDraft } from './views/career/CvImportMappingEditor';
let latest: MappingDraft[] = [];
function Harness() {
const [drafts, setDrafts] = useState<MappingDraft[]>([]);
return <I18nProvider><CvImportMappingEditor drafts={drafts} onChange={value => { latest = value; setDrafts(value); }} disabled={false}
categories={[{ category: 'jobs', fields: [{ name: 'title', multiple: false }, { name: 'company', multiple: false }, { name: 'bullets', multiple: true }] }]}
targets={[{ category: 'jobs', index: 0, label: 'Saved job', values: { title: 'Old title', company: 'Old company' } }]}
blocks={[{ id: 'b1', text: 'Engineer', kind: 'paragraph', page: 1 }, { id: 'b2', text: 'Example AS', kind: 'paragraph', page: 1 }]}
label={value => ({ jobs: 'Experience', title: 'Title', company: 'Company', bullets: 'Details' } as Record<string, string>)[value] ?? value} /></I18nProvider>;
}
beforeEach(() => { localStorage.clear(); latest = []; });
test('selecting source text fills an empty mapped value without approving or replacing a correction', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('Engineer');
expect(latest[0].confirmed).toBe(false);
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'My correction' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b2' } });
expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('My correction');
});
test('missing records require explicit source assignments and approval without a default existing target', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
expect(within(record).getByRole('combobox', { name: 'Destination' })).toHaveValue('');
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } });
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 2' }), { target: { value: 'Example AS' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0].confirmed).toBe(false);
expect(within(record).getByRole('alert')).toBeInTheDocument();
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 2' }), { target: { value: 'b2' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0]).toMatchObject({ category: 'jobs', targetIndex: null, confirmed: true, fields: [{ name: 'title', value: 'Engineer', sourceBlockId: 'b1', corrected: false }, { name: 'company', value: 'Example AS', sourceBlockId: 'b2', corrected: false }] });
fireEvent.change(within(record).getByRole('combobox', { name: 'Destination' }), { target: { value: '0' } });
expect(latest[0]).toMatchObject({ targetIndex: 0, confirmed: false });
expect(within(record).getByText('Current: Old title')).toBeInTheDocument();
expect(within(record).getByText('Current: Old company')).toBeInTheDocument();
});
test('unsupported wording requires an explicit correction and edits reset record approval', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
fireEvent.click(within(record).getByRole('button', { name: 'Remove field 2' }));
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Senior Engineer' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0].confirmed).toBe(false);
fireEvent.click(within(record).getByRole('checkbox', { name: 'This value is my correction' }));
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
expect(latest[0].confirmed).toBe(true);
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } });
expect(latest[0].confirmed).toBe(false);
fireEvent.click(within(record).getByRole('button', { name: 'Remove mapped record' }));
expect(latest).toEqual([]);
});
+26 -1
View File
@@ -6,6 +6,7 @@ import { emptyStructuredCv } from './profileCv';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import CareerProfilePage from './views/CareerProfilePage';
import type { MappingBlock, MappingCategory, MappingTarget } from './views/career/CvImportMappingEditor';
jest.mock('./api', () => ({
getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage,
@@ -22,7 +23,7 @@ const review = { runId: 23, status: 'pending_review', revision: 7, fileName: 'ca
{ id: 'Contact.fullName', label: 'Full name', currentValue: 'Saved name', extractedValue: 'Source name', sourceSnippet: 'Source name', sourcePage: 1, method: 'docling', reviewState: 'suggested' },
{ id: 'Contact.headline', label: 'Headline', currentValue: 'Saved headline', extractedValue: 'Engineer', sourceSnippet: null, sourcePage: null, method: 'deterministic', reviewState: 'suggested' },
] }] };
let currentReview = review;
let currentReview: typeof review & Partial<{ sourceBlocks: MappingBlock[]; mappingCategories: MappingCategory[]; targets: MappingTarget[] }> = review;
let runs = [run];
function renderImport() {
@@ -51,6 +52,30 @@ beforeEach(() => {
afterEach(() => jest.clearAllMocks());
test('a source-mapped missing record can be the only approved change and survives stale review without a stale target', async () => {
currentReview = { ...review, groups: [], sourceBlocks: [{ id: 'b1', text: 'Engineer', kind: 'paragraph', page: 1 }, { id: 'b2', text: 'Example AS', kind: 'paragraph', page: 1 }], mappingCategories: [{ category: 'jobs', fields: [{ name: 'title', multiple: false }, { name: 'company', multiple: false }] }], targets: [{ category: 'jobs', index: 0, label: 'Saved job' }] };
renderImport();
fireEvent.click(await screen.findByRole('button', { name: 'Create missing record' }));
const record = screen.getByRole('group', { name: 'Mapped record 1' });
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 1' }), { target: { value: 'Engineer' } });
fireEvent.change(within(record).getByRole('textbox', { name: 'Value 2' }), { target: { value: 'Example AS' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 1' }), { target: { value: 'b1' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Source evidence 2' }), { target: { value: 'b2' } });
fireEvent.change(within(record).getByRole('combobox', { name: 'Destination' }), { target: { value: '0' } });
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
currentReview = { ...currentReview, revision: 8 };
mockedApi.post.mockRejectedValueOnce({ response: { status: 409 } });
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/23/apply-reviewed', expect.objectContaining({ expectedVersion: 7, decisions: [], mappings: [expect.objectContaining({ category: 'jobs', targetIndex: 0 })] })));
await waitFor(() => expect(screen.getByRole('button', { name: 'Apply reviewed changes' })).toBeDisabled());
expect(within(record).getByRole('textbox', { name: 'Value 1' })).toHaveValue('Engineer');
expect(within(record).getByRole('combobox', { name: 'Destination' })).toHaveValue('');
fireEvent.click(within(record).getByRole('button', { name: 'Approve mapped record' }));
fireEvent.click(screen.getByRole('button', { name: 'Apply reviewed changes' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/profile-cv/runs/23/apply-reviewed', expect.objectContaining({ expectedVersion: 8, mappings: [expect.objectContaining({ category: 'jobs', targetIndex: null })] })));
expect(await screen.findByText('Reviewed changes saved to your Career Profile.')).toBeInTheDocument();
});
test('Docling review requires explicit decisions and sends only reviewed corrections before showing durable completion', async () => {
renderImport();
const choice = await screen.findByRole('combobox', { name: 'Decision: Full name' });
+44
View File
@@ -36,6 +36,28 @@ export const translations = {
cvImportStageExtracting: "Reading document structure",
cvImportStageValidating: "Validating your document",
cvImportFieldName: "Name",
cvImportMapMissing: "Create missing record",
cvImportMappingRecord: "Mapped record {number}",
cvImportMappingCategory: "Record section",
cvImportMappingTarget: "Destination",
cvImportMappingNew: "Add as a new record",
cvImportMappingContactDestination: "Update the displayed contact fields",
cvImportMappingAppend: "Append the approved values",
cvImportMappingField: "Field {number}",
cvImportMappingValue: "Value {number}",
cvImportMappingValueLabel: "Value",
cvImportMappingSource: "Source evidence {number}",
cvImportMappingSelect: "Choose source evidence",
cvImportMappingCorrection: "This value is my correction",
cvImportMappingAddField: "Add field",
cvImportMappingRemoveField: "Remove field {number}",
cvImportMappingConfirm: "Approve mapped record",
cvImportMappingConfirmed: "Mapped record approved",
cvImportMappingRemove: "Remove mapped record",
cvImportMappingEvidenceError: "Choose source evidence for every value. Use correction only for values you have checked.",
cvImportMappingNotice: "Map missing information from the source. Choose a destination and approve each record. These are your reviewed assignments.",
cvImportLinkLabel: "Link label",
cvImportQualificationLevel: "Stated qualification level",
cvImportFieldRole: "Role",
cvImportMatchedRecord: "Matches an existing record — review the affected fields",
cvImportNewRecord: "New record — keep only the information you want to add",
@@ -3013,6 +3035,28 @@ export const translations = {
cvImportStageExtracting: "Leser dokumentstrukturen",
cvImportStageValidating: "Kontrollerer dokumentet ditt",
cvImportFieldName: "Navn",
cvImportMapMissing: "Opprett manglende oppføring",
cvImportMappingRecord: "Tilordnet oppføring {number}",
cvImportMappingCategory: "Del av profilen",
cvImportMappingTarget: "Mål",
cvImportMappingNew: "Legg til som ny oppføring",
cvImportMappingContactDestination: "Oppdater de viste kontaktfeltene",
cvImportMappingAppend: "Legg til de godkjente verdiene",
cvImportMappingField: "Felt {number}",
cvImportMappingValue: "Verdi {number}",
cvImportMappingValueLabel: "Verdi",
cvImportMappingSource: "Kildegrunnlag {number}",
cvImportMappingSelect: "Velg kildegrunnlag",
cvImportMappingCorrection: "Denne verdien er min rettelse",
cvImportMappingAddField: "Legg til felt",
cvImportMappingRemoveField: "Fjern felt {number}",
cvImportMappingConfirm: "Godkjenn tilordnet oppføring",
cvImportMappingConfirmed: "Tilordnet oppføring er godkjent",
cvImportMappingRemove: "Fjern tilordnet oppføring",
cvImportMappingEvidenceError: "Velg kildegrunnlag for hver verdi. Bruk rettelse bare for verdier du har kontrollert.",
cvImportMappingNotice: "Tilordne manglende informasjon fra kilden. Velg mål og godkjenn hver oppføring. Dette er dine gjennomgåtte tilordninger.",
cvImportLinkLabel: "Lenkenavn",
cvImportQualificationLevel: "Oppgitt kvalifikasjonsnivå",
cvImportFieldRole: "Rolle",
cvImportMatchedRecord: "Samsvarer med en eksisterende oppføring — gjennomgå feltene som berøres",
cvImportNewRecord: "Ny oppføring — behold bare informasjonen du vil legge til",
@@ -0,0 +1,99 @@
import React, { useState } from "react";
import { Alert, Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel, NativeSelect, TextField, Typography } from "@mui/material";
import { useI18n } from "../../i18n/I18nProvider";
export type MappingCategory = { category: string; fields: { name: string; multiple: boolean; currentValue?: string | null }[] };
export type MappingBlock = { id: string; text: string; kind: string; page: number | null };
export type MappingTarget = { category: string; index: number; label: string; values?: Record<string, string | null> };
export type MappedField = { name: string; value: string; sourceBlockId: string; corrected: boolean };
export type MappingDraft = { clientId: string; category: string; targetIndex: number | null; fields: MappedField[]; confirmed: boolean };
const primary: Record<string, string[]> = { jobs: ["title", "company"], education: ["qualification", "institution"], certifications: ["name"], projects: ["name"], contact: ["fullName"], languages: ["name"], otherSections: ["title", "items"] };
const clean = (value: string) => value.replace(/\s+/g, " ").trim().toLocaleLowerCase();
const supported = (source: string, value: string) => new RegExp(`(?<![\\p{L}\\p{N}\\p{M}_])${clean(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}\\p{M}_])`, "iu").test(clean(source));
const empty = (name: string): MappedField => ({ name, value: "", sourceBlockId: "", corrected: false });
export default function CvImportMappingEditor({ categories, blocks, targets, drafts, onChange, disabled, label }: {
categories: MappingCategory[]; blocks: MappingBlock[]; targets: MappingTarget[]; drafts: MappingDraft[];
onChange: (drafts: MappingDraft[]) => void; disabled: boolean; label: (value: string) => string;
}) {
const { t } = useI18n();
const [errors, setErrors] = useState<Record<string, boolean>>({});
function update(index: number, value: Partial<MappingDraft>) {
onChange(drafts.map((draft, i) => i === index ? { ...draft, ...value, confirmed: false } : draft));
}
function defaultFields(category: MappingCategory) {
return (primary[category.category] ?? [category.fields[0]?.name ?? "value"]).filter(name => category.fields.some(field => field.name === name)).map(empty);
}
function approve(index: number) {
const draft = drafts[index];
const catalog = categories.find(category => category.category === draft.category);
const valid = draft.fields.length > 0 && draft.fields.every(field => {
const block = blocks.find(source => source.id === field.sourceBlockId);
return catalog?.fields.some(item => item.name === field.name) && field.value.trim().length > 0 && field.value.length <= 8000 && block && (field.corrected || supported(block.text, field.value));
});
setErrors(current => ({ ...current, [draft.clientId]: !valid }));
if (valid) onChange(drafts.map((item, i) => i === index ? { ...item, confirmed: true } : item));
}
if (categories.length === 0) return null;
return <Box sx={{ my: 2, minWidth: 0 }}>
<Typography variant="body2" sx={{ mb: 1 }}>{t("cvImportMappingNotice")}</Typography>
{drafts.map((draft, index) => {
const catalog = categories.find(category => category.category === draft.category);
const id = `cv-mapping-${draft.clientId}`;
return <Box component="fieldset" disabled={disabled} key={draft.clientId} sx={{ m: 0, mb: 2, p: 1.5, minWidth: 0, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Typography component="legend" variant="subtitle2">{t("cvImportMappingRecord", { number: index + 1 })}</Typography>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-category`} shrink>{t("cvImportMappingCategory")}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-category` }} value={draft.category} onChange={event => {
const category = categories.find(item => item.category === event.target.value)!;
update(index, { category: category.category, targetIndex: null, fields: defaultFields(category) });
}}>{categories.map(category => <option key={category.category} value={category.category}>{label(category.category)}</option>)}</NativeSelect>
</FormControl>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-target`} shrink>{t("cvImportMappingTarget")}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-target` }} value={draft.targetIndex ?? ""} onChange={event => update(index, { targetIndex: event.target.value === "" ? null : Number(event.target.value) })}>
<option value="">{t(draft.category === "contact" ? "cvImportMappingContactDestination" : catalog?.fields.every(field => field.name === "value") ? "cvImportMappingAppend" : "cvImportMappingNew")}</option>
{targets.filter(target => target.category === draft.category).map(target => <option key={target.index} value={target.index}>{target.label}</option>)}
</NativeSelect>
</FormControl>
{draft.fields.map((field, fieldIndex) => {
const number = fieldIndex + 1;
const currentValue = draft.targetIndex === null ? catalog?.fields.find(item => item.name === field.name)?.currentValue : targets.find(target => target.category === draft.category && target.index === draft.targetIndex)?.values?.[field.name];
function change(value: Partial<MappedField>) { update(index, { fields: draft.fields.map((item, i) => i === fieldIndex ? { ...item, ...value } : item) }); }
return <Box key={fieldIndex} sx={{ mb: 2, pt: 1, borderTop: "1px solid", borderColor: "divider" }}>
<FormControl variant="standard" fullWidth sx={{ mb: 2 }}>
<InputLabel htmlFor={`${id}-${fieldIndex}-field`} shrink>{t("cvImportMappingField", { number })}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-${fieldIndex}-field` }} value={field.name} onChange={event => change({ name: event.target.value })}>
{catalog?.fields.map(item => <option key={item.name} value={item.name}>{label(item.name)}</option>)}
</NativeSelect>
</FormControl>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", mb: 1 }}>{t("careerImportCurrentValue", { value: currentValue || t("careerImportEmptyValue") })}</Typography>
<TextField label={t("cvImportMappingValue", { number })} value={field.value} onChange={event => change({ value: event.target.value })} multiline fullWidth minRows={1} maxRows={8} inputProps={{ maxLength: 8000 }} />
<FormControl variant="standard" fullWidth sx={{ mt: 2 }}>
<InputLabel htmlFor={`${id}-${fieldIndex}-source`} shrink>{t("cvImportMappingSource", { number })}</InputLabel>
<NativeSelect inputProps={{ id: `${id}-${fieldIndex}-source` }} value={field.sourceBlockId} onChange={event => change({ sourceBlockId: event.target.value, value: field.value || blocks.find(block => block.id === event.target.value)?.text || "" })}>
<option value="">{t("cvImportMappingSelect")}</option>
{blocks.map(block => <option key={block.id} value={block.id}>{block.page ? `${t("cvImportPage", { page: block.page })}: ` : ""}{block.text.slice(0, 160)}</option>)}
</NativeSelect>
</FormControl>
{field.sourceBlockId ? <Typography variant="body2" sx={{ mt: 1, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{blocks.find(block => block.id === field.sourceBlockId)?.text}</Typography> : null}
<FormControlLabel control={<Checkbox checked={field.corrected} onChange={event => change({ corrected: event.target.checked })} />} label={t("cvImportMappingCorrection")} />
<Button size="small" color="inherit" onClick={() => update(index, { fields: draft.fields.filter((_, i) => i !== fieldIndex) })}>{t("cvImportMappingRemoveField", { number })}</Button>
</Box>;
})}
{errors[draft.clientId] ? <Alert severity="error" sx={{ mb: 1 }}>{t("cvImportMappingEvidenceError")}</Alert> : null}
{draft.confirmed ? <Alert severity="success" sx={{ mb: 1 }}>{t("cvImportMappingConfirmed")}</Alert> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button disabled={draft.fields.length >= 30} onClick={() => update(index, { fields: [...draft.fields, empty(catalog?.fields[0]?.name ?? "value")] })}>{t("cvImportMappingAddField")}</Button>
<Button variant="outlined" onClick={() => approve(index)}>{t("cvImportMappingConfirm")}</Button>
<Button color="inherit" onClick={() => onChange(drafts.filter((_, i) => i !== index))}>{t("cvImportMappingRemove")}</Button>
</Box>
</Box>;
})}
<Button disabled={disabled || blocks.length === 0 || drafts.length >= 25} onClick={() => {
const category = categories.find(item => item.category === "jobs") ?? categories[0];
onChange([...drafts, { clientId: crypto.randomUUID(), category: category.category, targetIndex: null, fields: defaultFields(category), confirmed: false }]);
}}>{t("cvImportMapMissing")}</Button>
</Box>;
}
@@ -3,6 +3,7 @@ import { Alert, Box, Button, FormControl, InputLabel, LinearProgress, NativeSele
import { api, getApiErrorMessage } from "../../api";
import { useI18n } from "../../i18n/I18nProvider";
import type { TranslationKey } from "../../i18n/translations";
import CvImportMappingEditor, { type MappingDraft, type MappingBlock, type MappingCategory, type MappingTarget } from "./CvImportMappingEditor";
type ReviewField = {
id: string; label: string; currentValue: string | null; extractedValue: string;
@@ -12,6 +13,7 @@ type ReviewGroup = { id: string; category: string; label: string; matchDescripti
type ImportReview = {
runId: number; status: string; revision: number; fileName: string; sourceText: string;
warnings: string[]; groups: ReviewGroup[];
sourceBlocks?: MappingBlock[]; mappingCategories?: MappingCategory[]; targets?: MappingTarget[];
};
type Choice = { action: "keep" | "use" | "correct" | "add"; value?: string; confirmed: boolean };
@@ -26,6 +28,7 @@ const labelKeys: Record<string, TranslationKey> = {
awards: "cvSectionAwards", publications: "cvSectionPublications", organisations: "cvSectionOrganisations", references: "cvSectionReferences",
qualification: "profileCvEducationQualification", institution: "profileCvEducationInstitution", level: "profileCvLanguageLevel", notes: "profileCvLanguageNotes",
name: "cvImportFieldName", issuer: "careerImportIssuer", date: "careerImportDates", role: "cvImportFieldRole", otherSections: "cvEditorCustom",
value: "cvImportMappingValueLabel", linkLabel: "cvImportLinkLabel", linkUrl: "profileCvContactWebsite", qualificationLevel: "cvImportQualificationLevel",
Contact: "careerProfileContactSection", "Full name": "profileCvContactFullName", Headline: "profileCvContactHeadline",
Email: "profileCvContactEmail", Phone: "profileCvContactPhone", Location: "profileCvContactLocation",
Website: "profileCvContactWebsite", LinkedIn: "profileCvContactLinkedIn", Summary: "profileCvStructuredSummary",
@@ -45,6 +48,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
translate.current = t;
const [review, setReview] = useState<ImportReview | null>(null);
const [choices, setChoices] = useState<Record<string, Choice>>({});
const [mappings, setMappings] = useState<MappingDraft[]>([]);
const [loading, setLoading] = useState(true);
const [comparisonCurrent, setComparisonCurrent] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,6 +85,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
useEffect(() => {
const requestState = generation;
setChoices({});
setMappings([]);
setStale(false);
void loadReview();
return () => { ++requestState.current; };
@@ -110,7 +115,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
const warningKeys: Record<string, TranslationKey> = { pdf_native_order_no_layout_or_tables: "cvImportWarningNative", docx_page_numbers_unavailable: "cvImportWarningPages", ocr_disabled: "cvImportWarningOcr" };
const fields = review?.groups.flatMap((group) => group.fields) ?? [];
const reviewedCount = fields.filter((field) => choices[field.id]?.confirmed).length;
const allReviewed = fields.length > 0 && reviewedCount === fields.length;
const allReviewed = (fields.length > 0 || mappings.length > 0) && reviewedCount === fields.length && mappings.every(mapping => mapping.confirmed);
async function apply() {
if (disabled || applyPending.current || !comparisonCurrent || !review || !allReviewed) return;
@@ -125,12 +130,14 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
const choice = choices[field.id];
return { id: field.id, action: choice.action, ...(choice.action === "correct" ? { value: choice.value ?? field.extractedValue } : {}) };
}),
...(mappings.length > 0 ? { mappings: mappings.map(({ clientId, category, targetIndex, fields: mappedFields }) => ({ clientId, category, targetIndex, fields: mappedFields })) } : {}),
});
setApplied(true);
await onApplied();
} catch (failure) {
if ((failure as { response?: { status?: number } }).response?.status === 409) {
setStale(true);
setMappings(current => current.map(mapping => ({ ...mapping, targetIndex: null, confirmed: false })));
setChoices((current) => Object.fromEntries(Object.entries(current).map(([id, choice]) => [id, { ...choice, confirmed: false }])));
await loadReview();
} else setError(getApiErrorMessage(failure, translate.current("profileCvApplyChangesFailed")));
@@ -209,6 +216,7 @@ export default function CvImportReview({ runId, disabled, onApplyingChange, onAp
})}
</Box>)}
{fields.length === 0 ? <Alert severity="info" sx={{ mb: 1 }}>{t("cvImportNoFields")}</Alert> : null}
<CvImportMappingEditor categories={review.mappingCategories ?? []} blocks={review.sourceBlocks ?? []} targets={review.targets ?? []} drafts={mappings} onChange={setMappings} label={label} disabled={disabled || applying || loading || !comparisonCurrent} />
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" disabled={disabled || applying || loading || !comparisonCurrent || !allReviewed} onClick={() => void apply()}>{t(applying ? "careerImportApplying" : "cvImportApplyReviewed")}</Button>
<Button color="inherit" disabled={disabled || applying} onClick={onDiscard}>{t("profileCvDiscard")}</Button>