100 lines
9.0 KiB
TypeScript
100 lines
9.0 KiB
TypeScript
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>;
|
|
}
|