feat(cv-qa): map missing records from reviewed source evidence
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user