import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useToast } from "../../hooks/useToast"; import { useI18n } from "../../i18n"; import { bizCompareCreateJob, bizCompareCreateMapping, bizCompareCreateTemplate, bizCompareDeleteJob, bizCompareDeleteRun, bizCompareDeleteTemplate, bizCompareDownloadRun, bizCompareGetRun, bizCompareListJobs, bizCompareListMappings, bizCompareListMetrics, bizCompareListRunDiffs, bizCompareListRuns, bizCompareListTemplates, bizCompareRunJob, bizCompareUpdateJob, bizCompareUpdateMapping, bizCompareUpdateTemplate, bizCompareValidateMapping, bizStateListBatches, bizStateListTasks, formatErr, } from "../../services/api"; import { formatSystemTime } from "../../utils/time"; import { jobChipColor, NmStatusChip } from "./nmChips"; type PageTab = "templates" | "jobs"; type JobDetailTab = "config" | "result"; type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged"; type CreateJobStep = 0 | 1 | 2 | 3; const CREATE_JOB_STEPS = 4; type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string }; type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null }; type MetricField = { name: string; display_name: string; dtype: string; is_key: boolean; is_interface: boolean; role: string; }; type MetricSchema = { metric_id: string; fields: MetricField[] }; type MetricSheet = { sheet_id?: string; title?: string; metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; display_fields?: string[]; row_filters?: RowFilter[]; field_rules?: FieldRule[]; }; type FieldRule = { field: string; compare?: string; normalize?: string; ignore?: boolean; tolerance?: number; }; type RowFilter = { field?: string; op?: string; value?: string | string[]; any?: RowFilter[]; all?: RowFilter[]; }; type Template = { id: string; name: string; metrics?: MetricSheet[]; metric_ids?: string[]; metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; iface_normalize_rules?: { from: string; to: string }[]; note: string; }; type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] }; type Job = { id: string; name: string; template_id: string; mapping_id: string; before_task_id: string; after_task_id: string; before_batch_id: string; after_batch_id: string; mode: string; status: string; enabled_sheet_ids?: string[]; note?: string; }; type DiffRow = { kind: string; key: Record; before: Record | null; after: Record | null; mapped_before?: Record | null; changes: Record; }; type RunSheet = { sheet_id?: string; title?: string; metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; display_fields?: string[]; field_rules?: FieldRule[]; mode?: string; summary?: Record; diffs?: DiffRow[]; }; function fmtTime(v?: string | null) { if (!v) return "—"; return formatSystemTime(v) || v; } function cellText(v: unknown): string { if (v === null || v === undefined) return ""; if (typeof v === "object") return JSON.stringify(v); return String(v); } function failFieldNames(d: DiffRow): string[] { if (d.kind === "added" || d.kind === "removed") return []; return Object.keys(d.changes || {}); } /** Prefer first non-empty row dict (API may return {} for null sides). */ function pickSideRow( ...candidates: Array | null | undefined> ): Record { for (const c of candidates) { if (c && typeof c === "object" && Object.keys(c).length > 0) return c; } return {}; } /** Non-key fields: always show before/after pair for cutover review. */ function PairCell(props: { beforeText: string; afterText: string; kind: string; mismatch: boolean; reason?: string; beforeLabel: string; afterLabel: string; }) { const { beforeText, afterText, kind, mismatch, reason, beforeLabel, afterLabel } = props; const pre = beforeText || "—"; const post = afterText || "—"; const isAdded = kind === "added"; const isRemoved = kind === "removed"; const isDiff = mismatch || isAdded || isRemoved; // Matched values: single line (no 前/后 stack) — cuts density on pass-heavy sheets. if (!isDiff) { return ( {pre} ); } // Whole-row missing/extra: emphasize the present side; do not strike it out. const preClass = [ "bs-cmp-val", !beforeText || isAdded ? "is-empty" : "", isRemoved && beforeText ? "bs-cmp-val--present-pre" : "", !isAdded && !isRemoved && mismatch && beforeText ? "bs-cmp-val--pre" : "", ] .filter(Boolean) .join(" "); const postClass = [ "bs-cmp-val", !afterText || isRemoved ? "is-empty" : "", isAdded && afterText ? "bs-cmp-val--present-post" : "", !isAdded && !isRemoved && mismatch && afterText ? "bs-cmp-val--post" : "", ] .filter(Boolean) .join(" "); return (
{beforeLabel} {isAdded ? "—" : pre}
{afterLabel} {isRemoved ? "—" : post}
{reason ?
{reason}
: null} ); } function toggleInList(list: string[], name: string, on: boolean): string[] { if (on) return list.includes(name) ? list : [...list, name]; return list.filter((x) => x !== name); } function sideDeviceName(side?: { ne_name?: string; ne_ip?: string; label?: string; batch_id?: string; } | null): string { if (!side) return "—"; const name = String(side.ne_name || "").trim(); const ip = String(side.ne_ip || "").trim(); const label = String(side.label || "").trim(); // Prefer real device identity; ignore hex-ish fallbacks that look like batch ids if (name) return name; if (ip) return ip; if (label && !/^[a-f0-9]{8,32}$/i.test(label)) return label; return "—"; } function sideCollectTime(side?: { started_at?: string | null } | null): string { const raw = side?.started_at; if (!raw) return "—"; const full = fmtTime(raw); if (!full || full === raw) { // Prefer compact local time when possible try { const d = new Date(/[zZ]$|[+-]\d{2}:\d{2}$/.test(String(raw)) ? raw : `${String(raw).replace(" ", "T")}Z`); if (!Number.isNaN(d.getTime())) { const m = d.getMonth() + 1; const day = d.getDate(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${m}/${day} ${hh}:${mm}`; } } catch { /* fall through */ } return full || "—"; } // Compact: drop seconds / year noise from locale string when long // e.g. "2026/9/18 09:35:53" -> "9/18 09:35" const m = String(full).match(/(?:\d{4}[/-])?(\d{1,2})[/-](\d{1,2})\s+(\d{2}):(\d{2})/); if (m) return `${m[1]}/${m[2]} ${m[3]}:${m[4]}`; return full; } type SideInfo = { ne_name?: string; ne_ip?: string; label?: string; batch_id?: string; started_at?: string | null; }; function enrichSide( side: Record | null | undefined, taskId: string, tasks: TaskOpt[], batches: BatchOpt[], ): SideInfo { const base: SideInfo = { ...(side || {}) } as SideInfo; const bid = String(base.batch_id || "").trim(); if (!base.started_at && bid) { const batch = batches.find((b) => b.id === bid); if (batch?.started_at) base.started_at = batch.started_at; } if (sideDeviceName(base) === "—") { const task = tasks.find((t) => t.id === taskId); if (task) { base.ne_name = task.ne_name || base.ne_name; base.ne_ip = task.ne_ip || base.ne_ip; base.label = task.ne_name || task.ne_ip || base.label; } } return base; } function taskLabel(row: TaskOpt) { return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; } function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string { return String(s?.sheet_id || s?.metric_id || "").trim(); } function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string { return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—"; } function templateSheets(tpl?: Template | null): MetricSheet[] { if (!tpl) return []; if (tpl.metrics?.length) return tpl.metrics; if (tpl.metric_id) { return [ { sheet_id: tpl.metric_id, title: tpl.metric_id, metric_id: tpl.metric_id, key_fields: [...(tpl.key_fields || [])], iface_fields: [...(tpl.iface_fields || [])], compare_fields: [...(tpl.compare_fields || [])], display_fields: [...(tpl.key_fields || []), ...(tpl.compare_fields || [])], row_filters: [], field_rules: [], }, ]; } return []; } function defaultSheetForMetric(schema: MetricSchema | undefined, metricId: string): MetricSheet { const fields = schema?.fields || []; const key_fields = fields.filter((f) => f.is_key).map((f) => f.name); const compare_fields = fields .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) .map((f) => f.name); const sheet: MetricSheet = { sheet_id: metricId, title: metricId, metric_id: metricId, key_fields, iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name), compare_fields, display_fields: [...key_fields, ...compare_fields], row_filters: [], field_rules: [], }; // Match backend ARP defaults when adding from UI if (metricId === "arp") { sheet.row_filters = [ { any: [ { field: "entry_type", op: "eq", value: "dynamic" }, { all: [ { field: "entry_type", op: "empty" }, { field: "age", op: "age_timer" }, ], }, ], }, ]; if (fields.some((f) => f.name === "mac")) { sheet.field_rules = [{ field: "mac", normalize: "mac" }]; } const ctx = ["vrf", "entry_type", "age"].filter( (n) => !key_fields.includes(n) && !compare_fields.includes(n) && fields.some((f) => f.name === n), ); sheet.display_fields = [...key_fields, ...compare_fields, ...ctx]; } return sheet; } function cloneSheet(s: MetricSheet): MetricSheet { const keys = [...(s.key_fields || [])]; const compare = [...(s.compare_fields || [])]; const display = s.display_fields && s.display_fields.length ? [...s.display_fields] : [...keys, ...compare]; return { sheet_id: s.sheet_id || s.metric_id, title: s.title || s.sheet_id || s.metric_id, metric_id: s.metric_id, key_fields: keys, iface_fields: [...(s.iface_fields || [])], compare_fields: compare, display_fields: display, row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), }; } const TEMPLATE_EXPORT_FORMAT = "netx.biz_compare_template"; const TEMPLATE_EXPORT_VERSION = 1; function templateExportPayload(tpl: Template) { const metrics = templateSheets(tpl).map((s) => ({ sheet_id: s.sheet_id || s.metric_id, title: s.title || s.sheet_id || s.metric_id, metric_id: s.metric_id, key_fields: [...(s.key_fields || [])], iface_fields: [...(s.iface_fields || [])], compare_fields: [...(s.compare_fields || [])], display_fields: s.display_fields && s.display_fields.length ? [...s.display_fields] : [...(s.key_fields || []), ...(s.compare_fields || [])], row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), })); return { format: TEMPLATE_EXPORT_FORMAT, version: TEMPLATE_EXPORT_VERSION, exported_at: new Date().toISOString(), name: tpl.name, note: tpl.note || "", iface_normalize_rules: [...(tpl.iface_normalize_rules || [])], metrics, }; } function parseTemplateImport(raw: unknown): { name: string; note: string; metrics: MetricSheet[]; iface_normalize_rules?: { from: string; to: string }[]; } | null { if (!raw || typeof raw !== "object") return null; const o = raw as Record; // Accept our export shape or a bare {name, metrics} / TemplateIn body const name = String(o.name || "").trim(); const note = String(o.note || ""); let metricsRaw = o.metrics; if (!Array.isArray(metricsRaw) && o.metric_id) { metricsRaw = [ { metric_id: o.metric_id, key_fields: o.key_fields || [], iface_fields: o.iface_fields || [], compare_fields: o.compare_fields || [], display_fields: o.display_fields || [], row_filters: o.row_filters || [], field_rules: o.field_rules || [], }, ]; } if (!name || !Array.isArray(metricsRaw) || !metricsRaw.length) return null; const metrics: MetricSheet[] = []; for (const item of metricsRaw) { if (!item || typeof item !== "object") continue; const m = item as Record; const mid = String(m.metric_id || "").trim(); const keys = Array.isArray(m.key_fields) ? (m.key_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : []; if (!mid || !keys.length) continue; metrics.push({ sheet_id: String(m.sheet_id || mid).trim() || mid, title: String(m.title || m.sheet_id || mid).trim() || mid, metric_id: mid, key_fields: keys, iface_fields: Array.isArray(m.iface_fields) ? (m.iface_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : [], compare_fields: Array.isArray(m.compare_fields) ? (m.compare_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : [], display_fields: Array.isArray(m.display_fields) ? (m.display_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) : undefined, row_filters: Array.isArray(m.row_filters) ? (m.row_filters as RowFilter[]) : [], field_rules: Array.isArray(m.field_rules) ? (m.field_rules as FieldRule[]) : [], }); } if (!metrics.length) return null; const normRaw = o.iface_normalize_rules; const iface_normalize_rules: { from: string; to: string }[] = []; if (Array.isArray(normRaw)) { for (const item of normRaw) { if (!item || typeof item !== "object") continue; const r = item as Record; const fr = String(r.from || "").trim(); const to = String(r.to || "").trim(); if (fr && to) iface_normalize_rules.push({ from: fr, to }); } } return { name, note, metrics, iface_normalize_rules }; } function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string { return (rules || []).map((r) => `${r.from},${r.to}`).join("\n"); } function parseIfaceNormText(text: string): { from: string; to: string }[] { const out: { from: string; to: string }[] = []; for (const line of text.split(/\r?\n/)) { const s = line.trim(); if (!s || s.startsWith("#")) continue; let fr = ""; let to = ""; if (s.includes(",")) { const i = s.indexOf(","); fr = s.slice(0, i).trim(); to = s.slice(i + 1).trim(); } else if (s.includes("\t")) { const i = s.indexOf("\t"); fr = s.slice(0, i).trim(); to = s.slice(i + 1).trim(); } else { const parts = s.split(/\s+/); if (parts.length >= 2) { fr = parts[0]; to = parts.slice(1).join(" "); } } if (fr && to) out.push({ from: fr, to }); } return out; } function downloadJsonFile(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function ruleForField(sheet: MetricSheet, field: string): FieldRule { return (sheet.field_rules || []).find((r) => r.field === field) || { field }; } function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial): FieldRule[] { const rules = [...(sheet.field_rules || [])]; const idx = rules.findIndex((r) => r.field === field); const next: FieldRule = { ...(idx >= 0 ? rules[idx] : { field }), ...patch, field }; const norm = (next.normalize || "").toLowerCase(); const cmp = (next.compare || "").toLowerCase(); const empty = !next.ignore && (!cmp || cmp === "eq") && (!norm || norm === "none" || norm === "strip") && (next.tolerance === undefined || next.tolerance === null); if (empty) { return rules.filter((r) => r.field !== field); } if (idx >= 0) rules[idx] = next; else rules.push(next); return rules; } function isLeafFilter(f: RowFilter): boolean { return !f.any && !f.all && Boolean(f.field || f.op); } /** Expand sheet row_filters into OR-groups of AND leaf conditions for editing. */ function toOrGroups(filters: RowFilter[]): RowFilter[][] { const list = filters || []; if (!list.length) return []; if (list.length === 1 && list[0]?.any && Array.isArray(list[0].any)) { return list[0].any .map((g) => { if (g?.all && Array.isArray(g.all)) return g.all.filter(isLeafFilter); if (isLeafFilter(g)) return [g]; return [] as RowFilter[]; }) .filter((g) => g.length); } if (list.every(isLeafFilter)) return [list.map((f) => ({ ...f }))]; // Mixed / opaque: keep editable leaves only const leaves = list.filter(isLeafFilter); return leaves.length ? [leaves] : []; } /** Serialize OR-groups back to engine row_filters (AND of leaves, or single any-of). */ function fromOrGroups(groups: RowFilter[][]): RowFilter[] { const clean = groups .map((g) => g .map((f) => ({ field: f.field || "", op: f.op || "eq", value: f.value ?? "", })) .filter((f) => f.field), ) .filter((g) => g.length); if (!clean.length) return []; if (clean.length === 1) return clean[0]; return [ { any: clean.map((g) => (g.length === 1 ? g[0] : { all: g })), }, ]; } function emptyLeaf(field = ""): RowFilter { return { field, op: "eq", value: "" }; } const FILTER_OPS = [ "eq", "ne", "in", "not_in", "contains", "empty", "not_empty", "regex", "age_timer", ] as const; function TplRowFiltersEditor({ groups, fieldOpts, t, onChange, }: { groups: RowFilter[][]; fieldOpts: string[]; t: (k: string, vars?: Record) => string; onChange: (next: RowFilter[][]) => void; }) { const setGroups = onChange; return (

{t("bizCompare.rowFiltersHintShort")}

{groups.length ? ( ) : null}
{!groups.length ?

{t("bizCompare.rowFiltersEmpty")}

: null} {groups.map((group, gi) => (
{gi > 0 ?
{t("bizCompare.filterOr")}
: null}
{t("bizCompare.filterGroupN", { n: String(gi + 1) })}
{group.map((filt, fi) => (
{fi > 0 ? (
{t("bizCompare.filterAnd")}
) : null}
{ const next = groups.map((g) => g.map((x) => ({ ...x }))); const op = next[gi][fi]?.op || "eq"; let value: string | string[] = e.target.value; if (op === "in" || op === "not_in") { value = e.target.value .split(",") .map((x) => x.trim()) .filter(Boolean); } next[gi][fi] = { ...next[gi][fi], value }; setGroups(next); }} />
))}
))}
); } function metricLabel(id: string) { return id; } export type BizComparePageMode = "jobs" | "templates" | "all"; export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePageMode }) { const { t } = useI18n(); const { showOk, showError } = useToast(); const [pageTab, setPageTab] = useState(pageMode === "templates" ? "templates" : "jobs"); // Route-bound modes must follow pageMode; internal tabs only when pageMode === "all". // Without this, React reuses the same component instance across // /biz-compare ↔ /compare-templates and pageTab stays stuck. const activeTab: PageTab = pageMode === "templates" ? "templates" : pageMode === "jobs" ? "jobs" : pageTab; const showTabSwitch = pageMode === "all"; const [busy, setBusy] = useState(false); const [tasks, setTasks] = useState([]); const [metrics, setMetrics] = useState([]); const [templates, setTemplates] = useState([]); const [mappings, setMappings] = useState([]); const [jobs, setJobs] = useState([]); const [listKw, setListKw] = useState(""); const debouncedListKw = useDebouncedValue(listKw, 250); // template editor const [tplOpen, setTplOpen] = useState(false); const [tplEditId, setTplEditId] = useState(""); const [tplName, setTplName] = useState(""); const [tplNote, setTplNote] = useState(""); const [tplNormText, setTplNormText] = useState(""); const [tplSheets, setTplSheets] = useState([]); const [tplSheetIdx, setTplSheetIdx] = useState(0); const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields"); const [showMetricPicker, setShowMetricPicker] = useState(true); // job create / detail const [jobCreateOpen, setJobCreateOpen] = useState(false); const [createStep, setCreateStep] = useState(0); const [jobId, setJobId] = useState(""); const [jobDetailTab, setJobDetailTab] = useState("config"); const [name, setName] = useState(""); const [templateId, setTemplateId] = useState(""); const [enabledSheetIds, setEnabledSheetIds] = useState([]); const [mappingId, setMappingId] = useState(""); const [beforeTaskId, setBeforeTaskId] = useState(""); const [afterTaskId, setAfterTaskId] = useState(""); const [beforeBatches, setBeforeBatches] = useState([]); const [afterBatches, setAfterBatches] = useState([]); const [beforeBatchId, setBeforeBatchId] = useState(""); const [afterBatchId, setAfterBatchId] = useState(""); const [mode, setMode] = useState<"manual" | "auto">("manual"); const [mapName, setMapName] = useState("端口映射"); const [mapText, setMapText] = useState(""); const [validateOut, setValidateOut] = useState(null); const [runs, setRuns] = useState([]); const [runDetail, setRunDetail] = useState(null); const [resultSheetId, setResultSheetId] = useState(""); // result filters (server-paged) const [kindFilter, setKindFilter] = useState("diff"); const [resultKw, setResultKw] = useState(""); const debouncedResultKw = useDebouncedValue(resultKw, 300); const [resultPage, setResultPage] = useState(1); const [resultPageSize, setResultPageSize] = useState(100); const [resultTotal, setResultTotal] = useState(0); const [pagedDiffs, setPagedDiffs] = useState([]); const [diffsLoading, setDiffsLoading] = useState(false); const boardRef = useRef(null); const [boardFs, setBoardFs] = useState(false); const tplImportRef = useRef(null); const refresh = useCallback(async () => { const [taskRes, tpl, maps, j, met] = await Promise.all([ bizStateListTasks(), bizCompareListTemplates(), bizCompareListMappings(), bizCompareListJobs(), bizCompareListMetrics(), ]); setTasks((taskRes.items || []) as TaskOpt[]); setTemplates((tpl.items || []) as Template[]); setMappings((maps.items || []) as Mapping[]); setJobs((j.items || []) as Job[]); setMetrics((met.items || []) as MetricSchema[]); }, []); useEffect(() => { void (async () => { try { await refresh(); } catch (e) { showError(formatErr(e)); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); // Keep local tab + dismiss overlays when route mode flips (component may be reused). useEffect(() => { if (pageMode === "templates") setPageTab("templates"); else if (pageMode === "jobs") setPageTab("jobs"); setTplOpen(false); setJobCreateOpen(false); setCreateStep(0); setJobId(""); setRuns([]); setRunDetail(null); setResultSheetId(""); setBoardFs(false); }, [pageMode]); useEffect(() => { void (async () => { if (!beforeTaskId) { setBeforeBatches([]); return; } const b = await bizStateListBatches(beforeTaskId); setBeforeBatches((b.items || []) as BatchOpt[]); })(); }, [beforeTaskId]); useEffect(() => { void (async () => { if (!afterTaskId) { setAfterBatches([]); return; } const b = await bizStateListBatches(afterTaskId); setAfterBatches((b.items || []) as BatchOpt[]); })(); }, [afterTaskId]); const filteredJobs = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return jobs; return jobs.filter((j) => { const tpl = templates.find((x) => x.id === j.template_id); const mids = (tpl?.metric_ids || templateSheets(tpl).map((s) => s.metric_id)).join(" "); return `${j.name} ${j.mode} ${j.status} ${tpl?.name || ""} ${mids}`.toLowerCase().includes(kw); }); }, [jobs, templates, debouncedListKw]); const filteredTemplates = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return templates; return templates.filter((x) => { const mids = (x.metric_ids || templateSheets(x).map((s) => s.metric_id)).join(" "); return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw); }); }, [templates, debouncedListKw]); const selectedJobTemplate = useMemo( () => templates.find((x) => x.id === templateId) || null, [templates, templateId], ); const jobTemplateSheets = useMemo( () => (selectedJobTemplate ? templateSheets(selectedJobTemplate) : []), [selectedJobTemplate], ); const jobSheetAllIds = useMemo( () => jobTemplateSheets.map((s) => sheetIdentity(s)).filter(Boolean), [jobTemplateSheets], ); const isJobSheetOn = useCallback( (sid: string) => { if (!enabledSheetIds.length) return true; return enabledSheetIds.includes(sid); }, [enabledSheetIds], ); const enabledJobSheetCount = useMemo(() => { if (!jobSheetAllIds.length) return 0; if (!enabledSheetIds.length) return jobSheetAllIds.length; return jobSheetAllIds.filter((id) => enabledSheetIds.includes(id)).length; }, [jobSheetAllIds, enabledSheetIds]); const toggleJobSheet = useCallback( (sid: string) => { const all = jobSheetAllIds; if (!all.length) return; const currentlyOn = !enabledSheetIds.length ? [...all] : enabledSheetIds.filter((id) => all.includes(id)); const next = currentlyOn.includes(sid) ? currentlyOn.filter((id) => id !== sid) : [...currentlyOn, sid]; // Empty list means "all on" (new template sheets auto-included) setEnabledSheetIds(next.length === all.length ? [] : next); }, [jobSheetAllIds, enabledSheetIds], ); const setJobTemplateAndSheets = useCallback( (nextTplId: string, presetIds?: string[] | null) => { setTemplateId(nextTplId); const tpl = templates.find((x) => x.id === nextTplId); const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : []; if (presetIds && presetIds.length) { const kept = presetIds.filter((id) => all.includes(id)); setEnabledSheetIds(kept.length === all.length ? [] : kept); } else { setEnabledSheetIds([]); } }, [templates], ); const activeTplSheet = tplSheets[tplSheetIdx] || null; const activeTplFields = useMemo(() => { if (!activeTplSheet) return []; return metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || []; }, [metrics, activeTplSheet]); const runSheets: RunSheet[] = useMemo(() => { const sheets = (runDetail?.sheets || []) as RunSheet[]; if (sheets.length) return sheets; if (runDetail?.diffs) { return [ { metric_id: String(runDetail.metric_id || "result"), key_fields: [], iface_fields: [], compare_fields: [], mode: "fields", summary: runDetail.summary, diffs: runDetail.diffs, }, ]; } return []; }, [runDetail]); useEffect(() => { if (!runSheets.length) { setResultSheetId(""); return; } if (!resultSheetId || !runSheets.some((s) => sheetIdentity(s) === resultSheetId)) { setResultSheetId(sheetIdentity(runSheets[0])); } }, [runSheets, resultSheetId]); const activeRunSheet = useMemo( () => runSheets.find((s) => sheetIdentity(s) === resultSheetId) || runSheets[0] || null, [runSheets, resultSheetId], ); // Reset page when sheet / filter / page size changes useEffect(() => { setResultPage(1); }, [resultSheetId, kindFilter, debouncedResultKw, resultPageSize, runDetail?.id]); useEffect(() => { const runId = String(runDetail?.id || ""); const mid = resultSheetId || sheetIdentity(activeRunSheet) || ""; if (!runId || !mid || jobDetailTab !== "result") { setPagedDiffs([]); setResultTotal(0); return; } let cancelled = false; void (async () => { setDiffsLoading(true); try { const res = await bizCompareListRunDiffs({ runId, metricId: mid, kind: kindFilter, kw: debouncedResultKw.trim(), page: resultPage, pageSize: resultPageSize, }); if (cancelled) return; setPagedDiffs((res.items || []) as DiffRow[]); setResultTotal(Number(res.total || 0)); const pages = Math.max( 1, Math.ceil(Number(res.total || 0) / Number(res.page_size || resultPageSize)), ); if (resultPage > pages) setResultPage(pages); } catch (e) { if (!cancelled) { // Keep previous rows to avoid strip/table jump; only clear on hard empty run showError(formatErr(e)); } } finally { if (!cancelled) setDiffsLoading(false); } })(); return () => { cancelled = true; }; }, [ runDetail?.id, resultSheetId, activeRunSheet?.metric_id, kindFilter, debouncedResultKw, resultPage, resultPageSize, jobDetailTab, showError, ]); const resultColumns = useMemo(() => { const keys = activeRunSheet?.key_fields?.length ? activeRunSheet.key_fields : Object.keys((pagedDiffs[0]?.key as Record) || {}); const compare = (activeRunSheet?.compare_fields || []).filter((f) => !keys.includes(f)); const compareSet = new Set(compare); const keySet = new Set(keys); let display = (activeRunSheet?.display_fields || []).filter(Boolean); if (!display.length) { display = [...keys, ...compare]; } // Column order: Key → Compare → Display-only (context) // Compare fields always appear (even if display unticked), matching backend. const compareCols = [...compare]; const displayOnly = display.filter((f) => !keySet.has(f) && !compareSet.has(f)); let extras = [...compareCols, ...displayOnly]; // Presence / sparse display: if no value columns, pull fields from sample // added/removed rows so 缺失/多余 still show side data. if (!extras.length && pagedDiffs.length) { const sample = pagedDiffs.find((d) => d.kind === "added" || d.kind === "removed") || pagedDiffs[0]; const side = pickSideRow( sample?.mapped_before as Record | undefined, sample?.before as Record | undefined, sample?.after as Record | undefined, ); extras = Object.keys(side).filter((f) => !keySet.has(f) && !f.startsWith("_")); } return { keys, compare: compareCols, extras, compareSet, presence: !(activeRunSheet?.compare_fields || []).length, }; }, [activeRunSheet, pagedDiffs]); const kindLabel = (kind: string) => { if (kind === "added") return t("bizCompare.kindAddedShort"); if (kind === "removed") return t("bizCompare.kindRemovedShort"); if (kind === "changed") return t("bizCompare.kindChangedShort"); if (kind === "unchanged") return t("bizCompare.kindUnchangedShort"); return kind; }; /** Table verdict: fail = missing+mismatch; success = match; added is special. */ const rowVerdict = (kind: string): { label: string; tone: "fail" | "pass" | "added" } => { if (kind === "added") return { label: t("bizCompare.kindAddedShort"), tone: "added" }; if (kind === "unchanged") return { label: t("bizCompare.kindSuccess"), tone: "pass" }; return { label: t("bizCompare.kindFail"), tone: "fail" }; }; const failFieldsLabel = (d: DiffRow) => { if (d.kind === "added") return t("bizCompare.kindAddedShort"); if (d.kind === "removed") return t("bizCompare.failWholeRow"); const names = failFieldNames(d); return names.length ? names.join(" · ") : t("bizCompare.failFieldsEmpty"); }; const sheetFailOf = (c: { fail_count?: number; diff_count?: number; removed?: number; changed?: number; added?: number; }) => { // Always derive from removed+changed so "新增" never counts as fail // (legacy diff_count used to include added). if (c.removed !== undefined || c.changed !== undefined) { return Number(c.removed || 0) + Number(c.changed || 0); } if (c.fail_count !== undefined) return Number(c.fail_count || 0); const legacy = Number(c.diff_count || 0); const added = Number(c.added || 0); return Math.max(0, legacy - added); }; const sheetSuccessOf = (c: { success_count?: number; unchanged?: number }) => Number(c.success_count ?? c.unchanged ?? 0); const sheetPassRateOf = (c: { pass_rate?: number; fail_count?: number; diff_count?: number; removed?: number; changed?: number; success_count?: number; unchanged?: number; added?: number; }) => { // Always recompute from fail/success so added never skews pass rate const fail = sheetFailOf(c); const ok = sheetSuccessOf(c); const judged = fail + ok; return judged ? Math.round((ok / judged) * 1000) / 10 : 100; }; const summary = runDetail?.summary || {}; const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const sheetCards = useMemo(() => { const raw = (summary.sheet_cards || []) as Array<{ sheet_id?: string; title?: string; metric_id: string; mode?: string; added?: number; removed?: number; changed?: number; unchanged?: number; before_count?: number; after_count?: number; fail_count?: number; success_count?: number; diff_count?: number; pass_rate?: number; }>; // Failures first so ops can scan quickly when many sheets return [...raw].sort((a, b) => { const da = sheetFailOf(a); const db = sheetFailOf(b); if (da !== db) return db - da; return String(a.metric_id).localeCompare(String(b.metric_id)); }); }, [summary.sheet_cards]); const activeSheetCard = sheetCards.find((c) => sheetIdentity(c) === resultSheetId) || sheetCards[0]; const activeFail = sheetFailOf(activeSheetCard || {}); const activeSuccess = sheetSuccessOf(activeSheetCard || {}); const activePassRate = sheetPassRateOf(activeSheetCard || {}); const activeAdded = Number(activeSheetCard?.added || 0); const showFailCol = kindFilter === "diff" || kindFilter === "all" || kindFilter === "added"; const resultEmptyColSpan = 1 + (showFailCol ? 1 : 0) + resultColumns.keys.length + Math.max(resultColumns.extras.length, 0); useEffect(() => { const syncFs = () => { const el = boardRef.current; setBoardFs(Boolean(el && document.fullscreenElement === el)); }; document.addEventListener("fullscreenchange", syncFs); return () => document.removeEventListener("fullscreenchange", syncFs); }, []); const toggleBoardFullscreen = async () => { const el = boardRef.current; if (!el) return; try { if (document.fullscreenElement === el) await document.exitFullscreen(); else await el.requestFullscreen(); } catch (e) { showError(formatErr(e)); } }; const downloadRunTables = async () => { if (!runDetail?.id) return; setBusy(true); try { await bizCompareDownloadRun(String(runDetail.id)); showOk(t("bizCompare.exportOk")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const updateActiveSheet = (patch: Partial) => { setTplSheets((prev) => prev.map((s, i) => { if (i !== tplSheetIdx) return s; const next = { ...s, ...patch }; // Keys are identity — drop from compare if (patch.key_fields || patch.compare_fields) { const keySet = new Set(next.key_fields); next.compare_fields = next.compare_fields.filter((f) => !keySet.has(f)); } if (patch.display_fields && !patch.key_fields && !patch.compare_fields) { // Explicit display toggle: keys always forced on const disp = new Set(patch.display_fields); for (const k of next.key_fields) disp.add(k); next.display_fields = [ ...next.key_fields, ...[...disp].filter((f) => !next.key_fields.includes(f)), ]; } else if (patch.key_fields || patch.compare_fields) { const disp = new Set(next.display_fields || []); for (const k of next.key_fields) disp.add(k); if (patch.compare_fields) { const prevCompare = new Set(s.compare_fields || []); for (const f of next.compare_fields) { if (!prevCompare.has(f)) disp.add(f); } } next.display_fields = [ ...next.key_fields, ...[...disp].filter((f) => !next.key_fields.includes(f)), ]; } return next; }), ); }; const openNewTemplate = () => { setTplEditId(""); setTplName(""); setTplNote(""); setTplNormText("XXVGE,xxvgei\nXGE,xgei\nCGE,cgei\nGE,gei\nSG,smartgroup"); setTplSheets([]); setTplSheetIdx(0); setTplPaneTab("fields"); setShowMetricPicker(true); setTplOpen(true); }; const openEditTemplate = (tpl: Template) => { const sheets = templateSheets(tpl); setTplEditId(tpl.id); setTplName(tpl.name); setTplNote(tpl.note || ""); setTplNormText(ifaceNormRulesToText(tpl.iface_normalize_rules)); setTplSheets(sheets.length ? sheets.map(cloneSheet) : []); setTplSheetIdx(0); setTplPaneTab("fields"); setShowMetricPicker(!sheets.length); setTplOpen(true); }; const toggleTplMetric = (metricId: string, on: boolean) => { if (on) { const schema = metrics.find((m) => m.metric_id === metricId); setTplSheets((prev) => { const next = [...prev, defaultSheetForMetric(schema, metricId)]; setTplSheetIdx(next.length - 1); if (!prev.length) setShowMetricPicker(false); return next; }); return; } // Uncheck removes ALL sheets of this source metric setTplSheets((prev) => { const next = prev.filter((s) => s.metric_id !== metricId); setTplSheetIdx((cur) => { if (!next.length) return 0; if (cur >= next.length) return next.length - 1; return cur; }); return next; }); }; const duplicateTplSheet = (idx: number) => { setTplSheets((prev) => { const src = prev[idx]; if (!src) return prev; const baseId = sheetIdentity(src); let n = 2; let candidate = `${baseId}.${n}`; const used = new Set(prev.map((s) => sheetIdentity(s))); while (used.has(candidate)) { n += 1; candidate = `${baseId}.${n}`; } const copy = cloneSheet(src); copy.sheet_id = candidate; copy.title = `${sheetLabel(src)} (${n})`; const next = [...prev]; next.splice(idx + 1, 0, copy); setTplSheetIdx(idx + 1); return next; }); }; const removeTplMetric = (idx: number) => { setTplSheets((prev) => { const next = prev.filter((_, i) => i !== idx); setTplSheetIdx((cur) => { if (!next.length) return 0; if (cur > idx) return cur - 1; if (cur >= next.length) return next.length - 1; return cur; }); return next; }); }; const saveTemplate = async () => { if (!tplSheets.length) { showError(t("bizCompare.metricsRequired")); return; } for (const s of tplSheets) { if (!s.key_fields.length) { showError(`${sheetLabel(s)}: ${t("bizCompare.keyRequired")}`); return; } if (!sheetIdentity(s)) { showError(`${s.metric_id}: ${t("bizCompare.sheetIdRequired")}`); return; } } const ids = tplSheets.map((s) => sheetIdentity(s)); if (new Set(ids).size !== ids.length) { showError(t("bizCompare.duplicateSheetId")); return; } setBusy(true); try { const body = { name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"), note: tplNote, iface_normalize_rules: parseIfaceNormText(tplNormText), metrics: tplSheets.map((s) => ({ sheet_id: sheetIdentity(s), title: s.title || sheetIdentity(s), metric_id: s.metric_id, key_fields: s.key_fields, iface_fields: s.iface_fields, compare_fields: s.compare_fields, display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], row_filters: s.row_filters || [], field_rules: s.field_rules || [], })), }; if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body); else await bizCompareCreateTemplate(body); showOk(t("bizCompare.templateSaved")); setTplOpen(false); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const removeTemplate = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteTemplate"))) return; setBusy(true); try { await bizCompareDeleteTemplate(id); showOk(t("bizCompare.templateDeleted")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const exportTemplate = (tpl: Template) => { const payload = templateExportPayload(tpl); const safe = (tpl.name || "template").replace(/[^\w\u4e00-\u9fff.-]+/g, "_").slice(0, 64); downloadJsonFile(`netx-compare-template-${safe}.json`, payload); showOk(t("bizCompare.templateExported")); }; const importTemplateFile = async (file: File) => { setBusy(true); try { const text = await file.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { showError(t("bizCompare.templateImportInvalid")); return; } const body = parseTemplateImport(parsed); if (!body) { showError(t("bizCompare.templateImportInvalid")); return; } await bizCompareCreateTemplate({ name: body.name, note: body.note, iface_normalize_rules: body.iface_normalize_rules || [], metrics: body.metrics.map((s) => ({ sheet_id: sheetIdentity(s), title: s.title || sheetIdentity(s), metric_id: s.metric_id, key_fields: s.key_fields, iface_fields: s.iface_fields, compare_fields: s.compare_fields, display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], row_filters: s.row_filters || [], field_rules: s.field_rules || [], })), }); showOk(t("bizCompare.templateImported")); await refresh(); if (pageMode !== "jobs") setPageTab("templates"); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); if (tplImportRef.current) tplImportRef.current.value = ""; } }; const removeJob = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteJob"))) return; setBusy(true); try { await bizCompareDeleteJob(id); if (jobId === id) closeJob(); showOk(t("bizCompare.jobDeleted")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const parseMapRows = () => { const rows: { before_if: string; after_if: string }[] = []; for (const line of mapText.split(/\r?\n/)) { const s = line.trim(); if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old") || s.toLowerCase().startsWith("before")) { continue; } const parts = s.split(/[,|\t]+/).map((x) => x.trim()); if (parts.length >= 2 && parts[0] && parts[1]) { rows.push({ before_if: parts[0], after_if: parts[1] }); } } return rows; }; const loadMappingText = (id: string) => { const m = mappings.find((x) => x.id === id); if (!m) return; setMappingId(id); setMapName(m.name); setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n")); }; const saveMapping = async () => { setBusy(true); try { const rows = parseMapRows(); if (mappingId) await bizCompareUpdateMapping(mappingId, { name: mapName, rows }); else { const m = await bizCompareCreateMapping({ name: mapName, rows }); setMappingId(String(m.id)); } showOk(t("bizCompare.mappingSaved")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const doValidate = async () => { if (!mappingId || !beforeBatchId || !afterBatchId) { showError(t("bizCompare.validateNeed")); return; } setBusy(true); try { const v = await bizCompareValidateMapping({ mapping_id: mappingId, before_batch_id: beforeBatchId, after_batch_id: afterBatchId, template_id: templateId, }); setValidateOut(v); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const resetJobForm = (preset?: Partial) => { const tplId = preset?.template_id || templates[0]?.id || ""; setName(preset?.name || t("bizCompare.defaultJobName")); setTemplateId(tplId); const tpl = templates.find((x) => x.id === tplId); const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : []; const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean); if (presetIds.length) { const kept = presetIds.filter((id) => all.includes(id)); setEnabledSheetIds(kept.length === all.length ? [] : kept); } else { setEnabledSheetIds([]); } setMappingId(preset?.mapping_id || ""); setBeforeTaskId(preset?.before_task_id || ""); setAfterTaskId(preset?.after_task_id || ""); setBeforeBatchId(preset?.before_batch_id || ""); setAfterBatchId(preset?.after_batch_id || ""); setMode(preset?.mode === "auto" ? "auto" : "manual"); setValidateOut(null); if (preset?.mapping_id) loadMappingText(preset.mapping_id); else { setMapName(t("bizCompare.mapping")); setMapText(""); } }; const jobConfigBody = () => ({ name, template_id: templateId, mapping_id: mappingId, before_task_id: beforeTaskId, after_task_id: afterTaskId || beforeTaskId, before_batch_id: beforeBatchId, after_batch_id: mode === "manual" ? afterBatchId : "", mode, enabled_sheet_ids: enabledSheetIds, }); const closeCreateJob = () => { setJobCreateOpen(false); setCreateStep(0); }; const openCreateJob = () => { resetJobForm(); setCreateStep(0); setJobCreateOpen(true); }; const canAdvanceCreateStep = (step: CreateJobStep): boolean => { if (step === 0) return Boolean(name.trim() && templateId); if (step === 1) return enabledJobSheetCount > 0; if (step === 2) { if (!beforeTaskId || !beforeBatchId) return false; if (mode === "manual" && !afterBatchId) return false; return true; } return true; }; const createStepBlockReason = (step: CreateJobStep): string | null => { if (step === 0) { if (!name.trim()) return t("bizCompare.needJobName"); if (!templateId) return t("bizCompare.needTemplate"); return null; } if (step === 1) { if (!enabledJobSheetCount) return t("bizCompare.needSheets"); return null; } if (step === 2) { if (!beforeTaskId || !beforeBatchId) return t("bizCompare.needBeforeBatch"); if (mode === "manual" && !afterBatchId) return t("bizCompare.needAfterBatch"); return null; } return null; }; const onCreateNext = () => { const reason = createStepBlockReason(createStep); if (reason) { showError(reason); return; } setCreateStep((s) => Math.min(CREATE_JOB_STEPS - 1, (s + 1) as CreateJobStep) as CreateJobStep); }; const onCreateBack = () => { setCreateStep((s) => Math.max(0, s - 1) as CreateJobStep); }; const createJob = async () => { const reason = createStepBlockReason(0) || createStepBlockReason(1) || createStepBlockReason(2); if (reason) { showError(reason); return; } setBusy(true); try { const j = await bizCompareCreateJob(jobConfigBody()); showOk(t("bizCompare.created")); closeCreateJob(); await refresh(); await openJob(String(j.id)); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const openJob = async (id: string) => { setJobId(id); setJobDetailTab("config"); setRunDetail(null); setResultSheetId(""); setKindFilter("diff"); setResultKw(""); const job = jobs.find((x) => x.id === id); if (job) resetJobForm(job); try { const r = await bizCompareListRuns(id); setRuns(r.items || []); if ((r.items || []).length) { const latest = await bizCompareGetRun(String((r.items as any[])[0].id)); setRunDetail(latest); setJobDetailTab("result"); } } catch (e) { showError(formatErr(e)); } }; const closeJob = () => { setJobId(""); setRuns([]); setRunDetail(null); setResultSheetId(""); }; const saveJobConfig = async () => { if (!jobId) return; if (!enabledJobSheetCount) { showError(t("bizCompare.needSheets")); return; } setBusy(true); try { await bizCompareUpdateJob(jobId, jobConfigBody()); showOk(t("bizCompare.jobSaved")); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const runNow = async () => { if (!jobId) return; if (!enabledJobSheetCount) { showError(t("bizCompare.needSheets")); return; } setBusy(true); try { await bizCompareUpdateJob(jobId, jobConfigBody()); const run = await bizCompareRunJob(jobId); setRunDetail(run); setJobDetailTab("result"); showOk(t("bizCompare.ran")); const r = await bizCompareListRuns(jobId); setRuns(r.items || []); await refresh(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const loadRun = async (runId: string) => { try { const d = await bizCompareGetRun(runId); setRunDetail(d); setKindFilter("diff"); setResultKw(""); setJobDetailTab("result"); } catch (e) { showError(formatErr(e)); } }; const removeRun = async (runId: string) => { if (!runId) return; if (!window.confirm(t("bizCompare.confirmDeleteRun"))) return; setBusy(true); try { const wasCurrent = String(runDetail?.id || "") === runId; await bizCompareDeleteRun(runId); let nextRuns: typeof runs = []; if (jobId) { const r = await bizCompareListRuns(jobId); nextRuns = r.items || []; } else { nextRuns = (runs || []).filter((r) => String(r.id) !== runId); } setRuns(nextRuns); if (wasCurrent) { if (nextRuns.length) { await loadRun(String(nextRuns[0].id)); } else { setRunDetail(null); setResultSheetId(""); } } showOk(t("bizCompare.runDeleted")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const renderSheetChips = () => (
{t("bizCompare.enabledSheets")} {enabledJobSheetCount}/{jobSheetAllIds.length || 0}

{t("bizCompare.enabledSheetsHint")}

{jobTemplateSheets.map((s) => { const sid = sheetIdentity(s); const on = isJobSheetOn(sid); return ( ); })}
{!jobTemplateSheets.length ? (

{t("bizCompare.noTemplateSheets")}

) : null} {jobTemplateSheets.length && !enabledJobSheetCount ? (

{t("bizCompare.needSheets")}

) : null}
); const renderMappingBlock = () => (

{t("bizCompare.mapping")}

{t("bizCompare.mappingOptionalHint")}

{ const id = e.target.value; if (id) loadMappingText(id); else { setMappingId(""); setMapText(""); } }} > {mappings.map((m) => ( ))} setMapName(e.target.value)} />