DEV Community

Mi Rak
Mi Rak

Posted on Edited on

css

css

/* Les flèches entre tables, à droite de l'arbre (T2.3.22) */
.sdm-lx-canvas { position: relative; }
.sdm-lx-arrows { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
.sdm-lx-arrow path { fill: none; stroke-width: 2; }
.sdm-lx-arrow.is-natif path { stroke: #047857; }
.sdm-lx-arrow.is-declare path { stroke: #6d28d9; stroke-dasharray: 6 4; }
.sdm-lx-arrow.is-parente path { stroke: #6b7280; stroke-dasharray: 2 3; }
.sdm-lx-arrow.is-natif circle, .sdm-lx-head-natif { fill: #047857; }
.sdm-lx-arrow.is-declare circle, .sdm-lx-head-declare { fill: #6d28d9; }
.sdm-lx-arrow.is-parente circle, .sdm-lx-head-parente { fill: #6b7280; }
.sdm-lx-arrow.is-inactive path { stroke-dasharray: 3 3; }
.sdm-lx-arrow.is-faded { opacity: .18; }
.sdm-lx-arrow.is-faded path { stroke-width: 1.5; }
.sdm-lx-arrow-label {
  position: absolute; z-index: 1; transform: translate(-50%, -50%); white-space: nowrap; cursor: pointer;
  padding: 1px 8px; border: 1px solid currentColor; border-radius: 999px; background: #fff;
  font-size: 11px; font-weight: 600;
}
.sdm-lx-arrow-label.sdm-lx-kind-natif { color: #047857; background: #fff; }
.sdm-lx-arrow-label.sdm-lx-kind-declare { color: #6d28d9; background: #fff; }
.sdm-lx-arrow-label.sdm-lx-kind-parente { color: #4b5563; background: #fff; }
.sdm-lx-arrow-label.is-faded { opacity: .35; }
.sdm-lx-arrow-label.is-hot { z-index: 2; }
.sdm-lx-arrow-label:focus-visible { outline: 2px solid #4680ff; outline-offset: 1px; }
.sdm-lx-goto { padding: 0; border: 0; background: none; color: #2f5fd0; font: inherit; font-weight: 700; cursor: pointer; }
.sdm-lx-goto:hover { text-decoration: underline; }
Enter fullscreen mode Exit fullscreen mode
/**
 * sdm-fichiers.utils.js — des deux fichiers déposés à la sortie attendue par
 * l'aiguillage : `{ ok, format, headers, fr, en }`.
 *
 * Comme Standards, un seul format par référentiel : le fichier officiel
 * (T2.3.21, demande du chef — plus de fichier plat fabriqué à la main) :
 *
 *   source   le référentiel a un traitement Standards (`BUILDERS[ref]`) :
 *            le fichier partagé par l'entreprise, transformé par ce
 *            traitement, appelé comme Standards l'appelle selon sa sorte :
 *              bilingual  deux classeurs FR et EN ;
 *              csv        une extraction CSV (+ la dépendance) ;
 *              workbook   un classeur, les deux langues dedans (+ dépendances) ;
 *              risks      deux extractions, niveaux 1 à 3 puis niveau 4 ;
 *              jobs       une extraction, deux compléments FR / EN facultatifs.
 *            Un fichier plat déposé pour ce référentiel est refusé.
 *
 *   plat     le référentiel n'a pas de traitement : son format officiel est
 *            le plat de Standards, en-tête en ligne 1 portant les colonnes de
 *            structure de la règle (code, parent, discriminant). Lu comme
 *            Standards le lit (`parseImportWorkbook`). Les colonnes EN sont
 *            remises dans l'ordre FR : l'aiguillage partage une seule ligne
 *            d'en-tête.
 *
 * Les extensions acceptées sont celles de Standards (`EXTENSIONS`). Les
 * en-têtes des classeurs FR / EN source sont remis au texte exact que le
 * traitement Standards attend avant de l'appeler : par position pour
 * GOVERNANCE (`headersByPosition`, ligne 4, colonnes A à G), par nom sans
 * majuscules, accents ni s final pour les régulateurs (`alignHeaders`).
 */

const XLSX = require('xlsx');
const standards = require('../../../controllers/admin/settings/settings-standards.utils');
const integrations = require('../../../controllers/admin/settings/standards-integrations.utils');

/**
 * Les fichiers qu'un import peut recevoir : [clé, champ du formulaire].
 * L'ordre fixe celui de la signature : FR puis EN d'abord, comme avant.
 */
const FILES = [
  ['fr', 'fileFr'], ['en', 'fileEn'], ['source', 'file'], ['l4', 'fileL4'], ['stdFr', 'fileStdFr'], ['stdEn', 'fileStdEn'],
];

/** Ce que chaque sorte de traitement attend, pour l'aide et les messages. */
const EXPECTED = {
  bilingual: 'les deux classeurs source FR et EN',
  csv: 'l\'extraction CSV',
  workbook: 'le classeur source (FR et EN dedans)',
  risks: 'les deux extractions : niveaux 1 à 3, puis niveau 4',
  jobs: 'l\'extraction des postes (compléments FR / EN facultatifs)',
};

/** Les sortes dont le traitement lit les référentiels dont il dépend. */
const WITH_DEPENDENCIES = ['csv', 'workbook', 'risks'];

const text = (v) => (v === undefined || v === null ? '' : String(v).trim());

/** Les extensions que Standards accepte, par sorte et par fichier ; `flat` sans traitement. */
const XLS = ['.xlsx', '.xls'];
const EXTENSIONS = {
  flat: { fr: XLS, en: XLS },
  bilingual: { fr: ['.xlsx'], en: ['.xlsx'] },
  csv: { source: ['.csv'] },
  workbook: { source: XLS },
  risks: { source: XLS, l4: XLS },
  jobs: { source: ['.xlsx'], stdFr: ['.xlsx'], stdEn: ['.xlsx'] },
};

/** Les extensions par fichier pour ce traitement (ou le plat sans traitement). */
const extensionsOf = (builder) => EXTENSIONS[builder ? builder.kind : 'flat'] || {};

/**
 * Le premier fichier reçu dont l'extension n'est pas celle de Standards :
 * message de refus, ou null. `received` : [[clé, { originalname }]].
 */
function checkExtensions(builder, received) {
  const allowed = extensionsOf(builder);
  for (const [key, f] of received) {
    const ok = allowed[key] || [];
    const name = text(f.originalname).toLowerCase();
    if (!ok.some((ext) => name.endsWith(ext))) {
      return ok.length
        ? `${f.originalname} : seuls les fichiers ${ok.join(' ou ')} sont acceptés ici`
        : `${f.originalname} : ce fichier n'est pas attendu pour ce référentiel`;
    }
  }
  return null;
}

/**
 * Les en-têtes que le traitement Standards attend dans les classeurs FR / EN
 * source. Ceux de GOVERNANCE recopient `GOV_SOURCE_COLUMNS`, que Standards
 * n'exporte pas ; ceux des régulateurs viennent de `REGULATORS`.
 */
const SOURCE_HEADERS = {
  GOVERNANCE: {
    fr: ['Procédure', 'Processus', 'Règle', 'Exigence', 'Référence Catalogue',
      'Standard(s) associé(s)', 'Modifications vs last version (Y/N)'],
    en: ['Procedure', 'Processus', 'Rule', 'Requirement', 'Catalogue Reference',
      'Associated Standard(s)', 'Modifications vs last version (Y/N)'],
  },
  ...Object.fromEntries(Object.entries(integrations.REGULATORS || {}).map(([code, spec]) => [code, spec.source])),
};

/** Un en-tête comparé sans majuscules, accents, espaces doublés ni s final de chaque mot. */
const loose = (s) => text(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
  .split(/\s+/).map((w) => w.replace(/s$/, '')).join(' ');

/**
 * Remet au texte exact attendu la ligne d'en-tête d'un classeur dont les
 * colonnes ne diffèrent que par la casse, les accents ou un s final. La ligne
 * est la première des dix premières où toutes les colonnes attendues se
 * retrouvent. Rend le buffer d'origine s'il n'y a rien à changer.
 */
function alignHeaders(buffer, expected) {
  if (!buffer || !expected) return buffer;
  let wb;
  try {
    wb = XLSX.read(buffer, { type: 'buffer', cellDates: false });
  } catch (_) {
    return buffer;
  }
  const ws = wb.Sheets[wb.SheetNames[0]];
  if (!ws || !ws['!ref']) return buffer;
  const range = XLSX.utils.decode_range(ws['!ref']);
  const wanted = new Map(expected.map((c) => [loose(c), c]));
  for (let r = range.s.r; r <= Math.min(range.e.r, range.s.r + 9); r++) {
    const cells = [];
    for (let c = range.s.c; c <= range.e.c; c++) {
      const address = XLSX.utils.encode_cell({ r, c });
      if (ws[address] && wanted.has(loose(ws[address].v))) cells.push(address);
    }
    const found = new Set(cells.map((a) => loose(ws[a].v)));
    if (found.size < wanted.size) continue;
    const changed = cells.filter((a) => ws[a].v !== wanted.get(loose(ws[a].v)));
    if (!changed.length) return buffer;
    changed.forEach((a) => { ws[a] = { t: 's', v: wanted.get(loose(ws[a].v)) }; });
    return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
  }
  return buffer;
}

/**
 * Les classeurs source lus par position, comme le traitement Standards les lit
 * (`GOV_COL`) : la ligne d'en-tête et le nombre de colonnes utiles (A à F).
 * Les titres changent d'une version et d'une langue à l'autre (« Associated
 * Standards », « Référence Catalogues ») : seule leur place compte.
 */
const BY_POSITION = { GOVERNANCE: { row: 4, useful: 6 } };

/**
 * Écrit en ligne `row` les en-têtes attendus, colonne par colonne, quel que
 * soit leur texte dans le fichier. Rend le buffer d'origine si une des
 * colonnes utiles est vide à cette ligne : le traitement le refusera alors.
 */
function headersByPosition(buffer, expected, { row, useful }) {
  if (!buffer || !expected) return buffer;
  let wb;
  try {
    wb = XLSX.read(buffer, { type: 'buffer', cellDates: false });
  } catch (_) {
    return buffer;
  }
  const ws = wb.Sheets[wb.SheetNames[0]];
  if (!ws || !ws['!ref']) return buffer;
  const address = (c) => XLSX.utils.encode_cell({ r: row - 1, c });
  for (let c = 0; c < useful; c++) if (!text(ws[address(c)] && ws[address(c)].v)) return buffer;
  if (expected.every((name, c) => ws[address(c)] && ws[address(c)].v === name)) return buffer;
  expected.forEach((name, c) => { ws[address(c)] = { t: 's', v: name }; });
  const range = XLSX.utils.decode_range(ws['!ref']);
  range.e.c = Math.max(range.e.c, expected.length - 1);
  ws['!ref'] = XLSX.utils.encode_range(range);
  return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}

/** Les colonnes qui signent un fichier plat pour cette règle. */
const structureColumns = (rule) => [rule.code, rule.parent, rule.column].filter(Boolean);

/** Lit un classeur comme Standards ; `null` s'il est illisible. */
function readFlat(buffer, parse) {
  try {
    const { columns, data } = parse(buffer);
    return { columns: columns.map(text), rows: data };
  } catch (_) {
    return null;
  }
}

/** Appelle le traitement d'une extraction source unique, selon sa sorte. */
function buildSource(builder, { sourceBuffer, l4Buffer, stdFrBuffer, stdEnBuffer, deps = {} }) {
  const dependencies = Object.keys(deps);
  switch (builder.kind) {
    case 'csv': {
      const rows = integrations.parseCsv(integrations.decode(sourceBuffer));
      if (rows.length < 2) return { ok: false, message: 'fichier vide' };
      const dep = dependencies[0];
      return builder.build(rows, dep ? { depFr: deps[dep].fr, depEn: deps[dep].en, dependency: dep } : null);
    }
    case 'workbook':
      return builder.build(sourceBuffer, dependencies.length ? { deps } : undefined);
    case 'risks':
      if (!l4Buffer) return { ok: false, message: `les deux extractions sont attendues : ${EXPECTED.risks}` };
      return builder.build({ l3Buffer: sourceBuffer, l4Buffer }, { deps });
    case 'jobs':
      return builder.build({ mainBuffer: sourceBuffer, stdFrBuffer, stdEnBuffer });
    default:
      return { ok: false, message: `traitement ${builder.kind} inconnu` };
  }
}

/**
 * @param {object} regle     la règle du référentiel (`ruleOf`)
 * @param {object} builder   traitement Standards du référentiel, ou null
 * @param {{frBuffer?, enBuffer?, sourceBuffer?, l4Buffer?, stdFrBuffer?, stdEnBuffer?, deps?, referential?}} fichiers
 *   deux classeurs FR et EN (plat ou bilingual), ou une extraction source ;
 *   `deps` : les référentiels dont le traitement dépend (`loadStandard`) ;
 *   `referential` : pour remettre les en-têtes source au texte attendu
 * @param {Function} [parse] lecteur de classeur plat (injectable pour les tests)
 */
function readOutput(rule, builder, files, parse = standards.parseImportWorkbook) {
  const { frBuffer, enBuffer, sourceBuffer } = files;
  const structure = structureColumns(rule);
  const isFlat = (l) => Boolean(l) && structure.every((c) => l.columns.includes(c));

  if (builder) {
    // Un fichier plat n'est plus le format officiel d'un référentiel traité.
    // Seuls les classeurs FR / EN se contrôlent : une extraction CSV peut
    // porter les mêmes colonnes de structure sans être un fichier plat, et
    // les zones FR / EN n'acceptent rien pour les autres sortes (`EXTENSIONS`).
    const flat = builder.kind === 'bilingual' && [frBuffer, enBuffer].filter(Boolean).some((b) => isFlat(readFlat(b, parse)));
    if (flat) {
      return { ok: false, message: `fichier plat refusé : ce référentiel s'importe avec le fichier officiel — ${EXPECTED[builder.kind] || 'l\'extraction source'}` };
    }
    let output;
    try {
      if (builder.kind === 'bilingual') {
        if (!(frBuffer && enBuffer)) return { ok: false, message: `déposez ${EXPECTED.bilingual}` };
        const expected = SOURCE_HEADERS[files.referential] || {};
        const position = BY_POSITION[files.referential];
        const align = (buffer, names) => (position ? headersByPosition(buffer, names, position) : alignHeaders(buffer, names));
        output = builder.build({ frBuffer: align(frBuffer, expected.fr), enBuffer: align(enBuffer, expected.en) });
      } else {
        if (!sourceBuffer) return { ok: false, message: `déposez ${EXPECTED[builder.kind] || 'l\'extraction source'}` };
        output = buildSource(builder, files);
      }
    } catch (e) {
      return { ok: false, message: `traitement ${builder.kind} impossible : ${e.message}` };
    }
    return output.ok ? { ...output, format: 'source' } : output;
  }

  if (!(frBuffer && enBuffer)) return { ok: false, message: 'déposez les deux fichiers FR et EN au format plat' };
  const [fr, en] = [frBuffer, enBuffer].map((b) => readFlat(b, parse));

  if (isFlat(fr) && isFlat(en)) {
    const missing = fr.columns.filter((c) => c && !en.columns.includes(c));
    if (missing.length) {
      return { ok: false, message: `colonnes absentes du fichier EN : ${missing.join(', ')}` };
    }
    const ordre = fr.columns.map((c) => en.columns.indexOf(c));
    return {
      ok: true,
      format: 'flat',
      headers: fr.columns,
      fr: fr.rows,
      en: en.rows.map((row) => ordre.map((i) => (i < 0 ? '' : row[i]))),
    };
  }
  return { ok: false, message: `format non reconnu : la ligne 1 des fichiers FR et EN doit porter ${structure.join(', ')}` };
}

module.exports = {
  readOutput, structureColumns, alignHeaders, checkExtensions, extensionsOf, FILES, EXPECTED, WITH_DEPENDENCIES,
};

Enter fullscreen mode Exit fullscreen mode
<section id="sdmPanelLinks" class="sdm-panel d-none" role="tabpanel" aria-labelledby="sdmTabLinks">
            <!-- Barre et arbre sur le modèle de la Hiérarchie Standards (E37) -->
            <div class="sdm-lx-bar">
                <p class="sdm-lx-help"><strong>Native</strong> : portée par une colonne du fichier, recalculée à chaque import.
                    <strong>À rapprocher</strong> : établie à la main, rejouée à chaque version ; en cas de doute, elle passe « à confirmer ».
                    L'escalier est la hiérarchie d'un référentiel ; les flèches à droite, les liens entre tables.</p>
                <div class="sdm-lx-group">
                    <label class="sdm-lx-check"><input type="checkbox" id="sdmAllArrows"> Toutes les flèches en clair</label>
                    <label class="sdm-lx-check"><input type="checkbox" id="sdmHideIsolated"> Masquer les isolés</label>
                    <button type="button" class="sdm-lx-btn" id="sdmBtnBackfillLinks" data-right="link">
                        <span data-icon="refresh"></span>Reprendre les liens existants
                    </button>
                    <button type="button" class="sdm-lx-btn sdm-lx-btn-primary" id="sdmBtnNewLink" data-right="link">
                        <span data-icon="plus"></span>Nouveau lien
                    </button>
                </div>
            </div>
Enter fullscreen mode Exit fullscreen mode
/**
 * sys_data_model — page « Modèle de données ».
 *
 *   Parcours    : Définir → Engendrer → Jouer → Importer → Lier, avec l'état réel de chaque étape.
 *   Entités     : arbre par référentiel, éditeur en tiroir, reprise depuis Standards, génération des migrations.
 *   Migrations  : fichiers engendrés, « marquer jouée », vue de compatibilité, écarts à la base.
 *   Imports     : analyse (n'écrit rien), puis intégration ou refus ; reprise des versions Standards.
 *   Liens       : arbre des entités et fiche à droite (E37), déclaration, reprise de l'existant,
 *                 instances des liens déclarés (tiroir), et en tête les liens douteux à trancher
 *                 un par un (bloc masqué s'il n'y en a pas).
 *   Test API    : les fonctions de l'API Standards, servies par sys-data-model.
 */
(function () {
  const API = '/ajax/sdm';
  const root = document.querySelector('.sdm-page');
  const right = (name) => root.dataset[name] === '1';
  const $ = (sel, inside = document) => inside.querySelector(sel);

  const state = { entities: [], referentials: [], importable: [], types: [], roles: [], syndications: ['none', 'user', 'admin'], editing: null };
  /** Ce qu'affiche le parcours ; null = pas encore chargé. */
  const summary = { entities: false, migrations: null, imports: null, links: null, confirm: null };

  // ── Outils ─────────────────────────────────────────────────────────────

  async function request(method, url, body) {
    const options = { method: method, headers: {} };
    if (body instanceof FormData) options.body = body;
    else if (body !== undefined) {
      options.headers['Content-Type'] = 'application/json';
      options.body = JSON.stringify(body);
    }
    // Jamais de réponse en cache : l'écran doit suivre la base (TH.5).
    options.cache = 'no-store';
    const response = await fetch(url, options);
    const json = await response.json().catch(() => ({ success: false, error: `Erreur ${response.status}` }));
    if (!json.success) throw new Error(json.error || `Erreur ${response.status}`);
    return json.data;
  }

  /** Crée un élément : el('td', { className: 'x' }, 'texte', enfant…). */
  function el(tag, props = {}, ...children) {
    const n = document.createElement(tag);
    for (const [k, v] of Object.entries(props)) {
      if (k === 'dataset') Object.assign(n.dataset, v);
      else if (k in n) n[k] = v;
      else n.setAttribute(k, v);
    }
    for (const e of children.flat()) if (e != null) n.append(e instanceof Node ? e : String(e));
    return n;
  }

  /** replaceChildren qui aplatit les listes et ignore les null (sinon « nullnull »). */
  function fill(node, ...children) {
    node.replaceChildren(...children.flat(Infinity).filter((e) => e != null));
    return node;
  }

  const ICONS = {
    entities: '<path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/>',
    migrations: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>',
    imports: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
    download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
    links: '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>',
    confirm: '<path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><path d="m22 4-10 10-3-3"/>',
    search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
    refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/>',
    play: '<path d="m6 4 14 8-14 8z"/>',
    undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-3"/>',
    plus: '<path d="M12 5v14M5 12h14"/>',
    code: '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
    file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
    close: '<path d="M18 6 6 18M6 6l12 12"/>',
    validate: '<path d="M20 6 9 17l-5-5"/>',
    define: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>',
    alert: '<path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>',
    arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
    up: '<path d="m18 15-6-6-6 6"/>',
    down: '<path d="m6 9 6 6 6-6"/>',
    trash: '<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>',
    split: '<path d="M6 3v6a6 6 0 0 0 6 6h0a6 6 0 0 1 6 6"/><path d="M6 21v-6"/><circle cx="6" cy="3" r="1"/><path d="M18 3v6"/>',
    empty: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z"/>',
    records: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/>',
    eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/><circle cx="12" cy="12" r="3"/>',
    external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
  };

  /** Icône SVG en ligne (chaînes fixes ci-dessus, jamais de donnée). */
  function icon(name) {
    const n = el('span', { className: 'sdm-icon', 'aria-hidden': 'true' });
    n.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name] || ''}</svg>`;
    return n;
  }

  /** Remplace les <span data-icone="…"> du gabarit. */
  function setIcons(inside = document) {
    inside.querySelectorAll('[data-icon]').forEach((n) => {
      const i = icon(n.dataset.icon);
      i.className = `sdm-icon ${n.className}`.trim();
      n.replaceWith(i);
    });
  }

  const notify = (type, text) => window.x1Notification(type, text);
  const label = (l) => (l && (l[window.getUserLang()] || l.fr || l.en)) || '';
  const ENTITY_STATES = { creee: 'créées', engendree: 'migration en attente', a_engendrer: 'à engendrer' };
  const IMPORT_STATES = { analyse: 'à valider', perime: 'périmée', integre: 'intégré', refuse: 'refusé', valide: 'validé' };
  const MIGRATION_STATES = { a_jouer: 'à jouer', jouee: 'jouée' };
  const TABLE_STATES = { creee: 'créée', engendree: 'migration en attente', a_engendrer: 'à engendrer', sans_table: '—' };
  const DECISION_STATES = { propose: 'à confirmer', confirme: 'confirmé', reconduit: 'reconduit', rejete: 'rejeté', caduc: 'caduc' };
  const date = (d) => (d ? new Date(d).toLocaleString() : '');
  const plural = (n, one, several) => `${n} ${n > 1 ? several : one}`;
  const stateDot = (cls, text, title) => el('span', { className: `sdm-state sdm-state-${cls}`, title: title || '' }, text);
  const dot = (text, cls = '') => el('span', { className: `sdm-dot ${cls}`.trim() }, text);

  function size(bytes) {
    if (bytes < 1024) return `${bytes} o`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`;
    return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
  }

  /**
   * Le bouton est désactivé et montre un spinner le temps de l'action ;
   * au moins 400 ms, pour qu'une action rapide se voie quand même.
   */
  async function busy(button, fn) {
    button.disabled = true;
    button.classList.add('sdm-running');
    button.setAttribute('aria-busy', 'true');
    const start = Date.now();
    try { await fn(); } catch (e) { notify('alert', e.message); } finally {
      const rest = 400 - (Date.now() - start);
      if (rest > 0) await new Promise((r) => setTimeout(r, rest));
      button.classList.remove('sdm-running');
      button.removeAttribute('aria-busy');
      button.disabled = Boolean(button.dataset.reason);
      updateButtons();
    }
  }

  /**
   * Une tâche longue (TH.12) : la requête part avec `?background=1` et rend un
   * numéro ; une barre sous le bouton suit l'avancement jusqu'à la fin.
   * Rend le résultat de la tâche, ou lève son erreur.
   */
  async function runTask(button, method, url, body) {
    const { task } = await request(method, `${url}${url.includes('?') ? '&' : '?'}background=1`, body);
    const bar = el('div', { className: 'sdm-progress-bar' });
    const text = el('span', { className: 'sdm-progress-text' }, 'démarrage');
    const box = el('div', {
      className: 'sdm-progress', role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': '0',
    }, el('div', { className: 'sdm-progress-track' }, bar), text);
    const slot = button?.closest('.sdm-report-decision, form, .sdm-bar') || button?.parentNode;
    if (slot) slot.after(box);
    try {
      for (;;) {
        const t = await request('GET', `${API}/tasks/${task}`);
        const pct = Math.round((t.fraction || 0) * 100);
        bar.style.width = `${pct}%`;
        box.setAttribute('aria-valuenow', String(pct));
        text.textContent = `${pct} % — ${t.step}`;
        if (t.state === 'fini') return t.result;
        if (t.state === 'error') throw new Error(t.error);
        await new Promise((r) => setTimeout(r, 700));
      }
    } finally {
      box.remove();
    }
  }

  /**
   * Fenêtre de confirmation (<dialog> natif : fond, Échap, focus piégé).
   * Résout true si l'utilisateur valide, false sinon.
   */
  function askConfirmation({ title, text, list = [], validate = 'Confirmer', danger = false }) {
    return new Promise((resolve) => {
      const validateButton = el('button', { type: 'submit', value: 'oui', className: `btn btn-sm ${danger ? 'btn-danger' : 'btn-primary'}` }, validate);
      const dialog = el('dialog', { className: 'sdm-dialog', 'aria-labelledby': 'sdmDialogTitle' },
        el('form', { method: 'dialog' },
          el('div', { className: 'sdm-dialog-body' },
            el('h4', { id: 'sdmDialogTitle' }, title),
            el('p', {}, text),
            list.length ? el('ul', { className: 'sdm-dialog-list' }, ...list.map((x) => el('li', { className: 'sdm-code' }, x))) : null),
          el('div', { className: 'sdm-drawer-footer' },
            el('button', { type: 'submit', value: 'non', className: 'btn btn-outline-secondary btn-sm' }, 'Annuler'),
            validateButton)));
      dialog.addEventListener('close', () => {
        resolve(dialog.returnValue === 'oui');
        dialog.remove();
      });
      // Dans .sdm-page : les couleurs de la page y sont définies (sinon fond transparent).
      (document.querySelector('.sdm-page') || document.body).append(dialog);
      dialog.showModal();
      validateButton.focus();
    });
  }

  /** Grise un bouton qui n'a pas de sens maintenant, et dit pourquoi au survol. */
  function dim(button, reason, title = '') {
    if (!button) return;
    button.dataset.reason = reason || '';
    button.disabled = Boolean(reason);
    button.title = reason ? `Indisponible : ${reason}` : title;
  }

  /** Les boutons de la page selon l'état : `etat.gestes` vient du serveur, avec ses propres contrôles. */
  function updateButtons() {
    const g = state.actions;
    if (!g) return;
    const filter = $('#sdmFilterRef').value;
    const n = filter ? (g.toBackfill[filter] || 0) : Object.values(g.toBackfill).reduce((a, b) => a + b, 0);
    dim($('#sdmBtnBackfill'), n ? null : `toutes les entités de ${filter || 'Standards'} sont déjà déclarées`,
      `${plural(n, 'entité', 'entités')} à reprendre`);
    dim($('#sdmBtnPlay'), g.play);
    dim($('#sdmBtnReset'), g.reset, 'DEV : vide tout ce que sys_data_model a écrit');
    dim($('#sdmBtnDiff'), g.diff);
    const refView = $('#sdmRefView').value;
    dim($('#sdmBtnView'), refView ? g.view[refView] : 'aucune entité déclarée');
    const importRef = $('#sdmFormImport').elements.referential.value;
    const obstacle = !importRef ? 'aucun référentiel importable'
      : importRef in g.import ? g.import[importRef] : `import non disponible pour ${importRef}`;
    dim($('#sdmBtnAnalyze'), obstacle);
    dim($('#sdmBtnBackfillData'), obstacle);
    dim($('#sdmBtnBackfillLinks'), g.links);
    document.querySelectorAll('[data-generate]').forEach((b) => dim(b, g.migration[b.dataset.generate]));
    dim($('#sdmBtnGenerateAll'), g.migration['*']);
  }

  function applyRights() {
    document.querySelectorAll('[data-right]').forEach((n) => {
      if (!right(n.dataset.right)) n.classList.add('d-none');
    });
  }

  /** État vide illustré. */
  function empty(name, title, text) {
    return el('div', { className: 'sdm-empty' }, icon(name), el('strong', {}, title), text ? el('p', {}, text) : null);
  }

  /** Une table : la carte si des lignes, sinon l'état vide. */
  function fillTable(card, body, rows, emptyZone, emptyArgs) {
    $(body).replaceChildren(...rows);
    $(card).classList.toggle('d-none', rows.length === 0);
    $(emptyZone).replaceChildren(...(rows.length ? [] : [empty(...emptyArgs)]));
  }

  /** Lignes fantômes pendant le premier chargement. */
  function skeleton(body, columns) {
    const tbody = $(body);
    if (tbody.rows.length) return;
    tbody.replaceChildren(...[0, 1, 2].map(() => el('tr', { className: 'sdm-row-skeleton' },
      ...Array.from({ length: columns }, () => el('td', {}, el('span', {}))))));
  }

  // ── Parcours ───────────────────────────────────────────────────────────

  function showPath() {
    const n = state.entities.length;
    const toGenerate = state.entities.filter((e) => e.state === 'a_engendrer').length;
    const m = summary.migrations;
    const toPlay = m ? m.filter((x) => x.state === 'a_jouer').length : 0;
    const im = summary.imports;
    const integrated = im ? im.filter((x) => x.SDM_IM_State === 'integre').length : 0;
    const toValidate = im ? im.filter((x) => x.SDM_IM_State === 'analyse').length : 0;
    const li = summary.links;
    const active = li ? li.filter((x) => x.active).length : 0;
    const toConfirm = summary.confirm ? summary.confirm.length : 0;

    const steps = [
      {
        name: 'Définir', icon: 'define', tab: 'entities', figure: n, loaded: summary.entities,
        text: plural(n, 'entité', 'entités'), cls: n ? 'ok' : 'empty',
      },
      {
        name: 'Engendrer', icon: 'code', tab: 'entities', figure: toGenerate, loaded: summary.entities,
        text: toGenerate ? 'à engendrer' : (n ? 'tout est engendré' : 'rien à engendrer'),
        cls: toGenerate ? 'pending' : (n ? 'ok' : 'empty'),
      },
      {
        name: 'Jouer', icon: 'play', tab: 'migrations', figure: toPlay, loaded: m !== null,
        text: toPlay ? `${toPlay > 1 ? 'migrations' : 'migration'} à jouer` : 'base à jour',
        cls: toPlay ? 'alert' : (m && m.length ? 'ok' : 'empty'),
      },
      {
        name: 'Importer', icon: 'imports', tab: 'imports', figure: integrated, loaded: im !== null,
        text: `${integrated > 1 ? 'versions intégrées' : 'version intégrée'}${toValidate ? ` · ${toValidate} à valider` : ''}`,
        cls: toValidate ? 'pending' : (integrated ? 'ok' : 'empty'),
      },
      {
        name: 'Lier', icon: 'links', tab: 'links', figure: active,
        loaded: li !== null && summary.confirm !== null,
        text: `${active > 1 ? 'liens actifs' : 'lien actif'}${toConfirm ? ` · ${toConfirm} à confirmer` : ''}`,
        cls: toConfirm ? 'pending' : (active ? 'ok' : 'empty'),
      },
    ];

    $('#sdmPath').replaceChildren(...steps.map((e, i) => {
      const loaded = e.loaded !== false;
      return el('li', {}, el('button', {
        type: 'button',
        className: `sdm-step sdm-step-${loaded ? e.cls : 'chargement'}`,
        title: `Ouvrir : ${e.name}`,
        onclick: () => openTab(e.tab, true),
      },
      el('span', { className: 'sdm-step-circle' }, e.cls === 'ok' && loaded ? icon('validate') : String(i + 1)),
      el('span', { className: 'sdm-step-name' }, e.name),
      el('span', { className: 'sdm-step-figure' }, loaded ? String(e.figure) : ''),
      el('span', { className: 'sdm-step-text' }, loaded ? e.text : 'chargement…')));
    }));
  }

  // ── Onglets ────────────────────────────────────────────────────────────

  function openTab(name, focus = false) {
    document.querySelectorAll('[data-tab]').forEach((b) => {
      const active = b.dataset.tab === name;
      b.classList.toggle('active', active);
      b.setAttribute('aria-selected', String(active));
      b.tabIndex = active ? 0 : -1;
      if (active && focus) b.focus();
      $(`#${b.getAttribute('aria-controls')}`).classList.toggle('d-none', !active);
    });
    const load = {
      imports: loadImports, migrations: loadMigrations, records: loadData, links: () => Promise.all([loadLinks(), loadConfirm()]), api: loadApi,
    }[name];
    if (load) load().catch((e) => notify('alert', e.message));
  }

  /** Flèches gauche/droite, Début, Fin entre les onglets. */
  function tabsKeyboard(ev) {
    const tabs = [...document.querySelectorAll('[data-tab]')];
    const i = tabs.indexOf(document.activeElement);
    if (i < 0) return;
    const target = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[ev.key];
    if (target === undefined) return;
    ev.preventDefault();
    openTab(tabs[(target + tabs.length) % tabs.length].dataset.tab, true);
  }

  // ── Tiroirs ────────────────────────────────────────────────────────────

  let returnFocus = null;

  function openDrawer(sel) {
    if (!document.querySelector('.sdm-drawer.ouvert')) returnFocus = document.activeElement;
    document.querySelectorAll('.sdm-drawer.ouvert').forEach((t) => t.classList.remove('ouvert'));
    $(sel).classList.add('ouvert');
    $('#sdmOverlay').classList.add('ouvert');
    document.body.classList.add('sdm-no-scroll');
  }

  function closeDrawer() {
    if (!document.querySelector('.sdm-drawer.ouvert')) return;
    document.querySelectorAll('.sdm-drawer.ouvert').forEach((t) => t.classList.remove('ouvert'));
    $('#sdmOverlay').classList.remove('ouvert');
    document.body.classList.remove('sdm-no-scroll');
    openLink = null;
    if (returnFocus && document.contains(returnFocus)) returnFocus.focus();
    returnFocus = null;
  }

  // ── Entités ────────────────────────────────────────────────────────────

  async function loadEntities() {
    const data = await request('GET', `${API}/entities`);
    Object.assign(state, data);
    summary.entities = true;
    fillFilter();
    showEntities();
    showPath();
    $('#sdmBtnPlay').classList.toggle('d-none', !(data.playMigrations && right('generate')));
    $('#sdmBtnReset').classList.toggle('d-none', !(data.playMigrations && right('mark')));
    updateButtons();
  }

  const knownReferentials = () => [...new Set([...state.referentials.map((r) => r.code), ...state.entities.map((e) => e.Referentiel)])].sort();

  const refSheet = (code) => state.referentials.find((r) => r.code === code);
  /** Le nom d'un référentiel dans la langue active (TH.7) ; le code reste la valeur. */
  const refName = (code) => {
    const name = label(refSheet(code)?.name);
    return name && name !== code ? `${name} (${code})` : code;
  };
  /** La section d'un référentiel (TH.11), lue dans sa fiche SDM ; '' si aucune. */
  const refSection = (code) => refSheet(code)?.section || '';

  /**
   * Les options d'une liste de référentiels, rangées par section comme dans
   * Standards (TH.11) : un <optgroup> par section, les autres à la fin.
   */
  function refOptions(codes) {
    const bySection = new Map();
    for (const c of codes) {
      const s = refSection(c);
      if (!bySection.has(s)) bySection.set(s, []);
      bySection.get(s).push(el('option', { value: c }, refName(c)));
    }
    const sections = [...bySection.keys()].filter(Boolean).sort();
    if (!sections.length) return bySection.get('') || [];
    return [
      ...sections.map((s) => el('optgroup', { label: s }, ...bySection.get(s))),
      ...(bySection.has('') ? [el('optgroup', { label: 'Sans section' }, ...bySection.get(''))] : []),
    ];
  }

  function fillFilter() {
    const filter = $('#sdmFilterRef');
    const chosen = filter.value;
    const codes = knownReferentials();
    fill(filter, el('option', { value: '' }, 'Tous les référentiels'), refOptions(codes));
    filter.value = codes.includes(chosen) ? chosen : '';
  }

  function generate(referential) {
    return (ev) => busy(ev.currentTarget, async () => {
      const r = await request('POST', `${API}/migrations`, { referential });
      // T2.3.16 : un toast court ; le détail est dans l'onglet Migrations.
      notify('success', 'Migration engendrée avec succès.');
      await loadEntities();
      await loadMigrations().catch(() => {});
    });
  }

  function entityNode(e, children, seen) {
    seen.add(e.Code);
    const meta = [
      e.ParenteCode === e.Code ? dot('hiérarchie interne', 'sdm-dot-accent') : null,
      e.Split ? dot(`${e.Split.column} = ${e.Split.value}`, 'sdm-dot-accent') : null,
      e.Actif ? null : dot('inactive'),
      dot(plural(e.attributes.length, 'attribut', 'attributs')),
      stateDot(e.state, ENTITY_STATES[e.state] || e.state, e.migration || ''),
    ];
    const sub = (children.get(e.Code) || []).filter((x) => !seen.has(x.Code));
    return el('li', {},
      el('button', {
        type: 'button',
        className: `sdm-node${e.Actif ? '' : ' sdm-inactive'}`,
        onclick: () => edit(e.Code),
        title: right('write') ? 'Modifier' : 'Voir',
      },
      el('span', { className: 'sdm-node-icon' }, icon('entities')),
      el('span', { className: 'sdm-node-code' }, e.Code),
      el('span', { className: 'sdm-node-label' }, label(e.Libelle)),
      el('span', { className: 'sdm-node-meta' }, ...meta)),
      sub.length ? el('ul', { className: 'sdm-nodes' }, ...sub.map((x) => entityNode(x, children, seen))) : null);
  }

  function showEntities() {
    const filter = $('#sdmFilterRef').value;
    const text = $('#sdmSearch').value.trim().toLowerCase();
    const matches = (e) => !text || e.Code.toLowerCase().includes(text)
      || Object.values(e.Libelle || {}).some((l) => String(l).toLowerCase().includes(text));
    const list = state.entities.filter((e) => (!filter || e.Referentiel === filter) && matches(e));
    const tree = $('#sdmTree');

    if (!state.entities.length) {
      tree.replaceChildren(empty('entities', 'Aucune entité déclarée',
        'Reprenez les entités depuis Standards, ou déclarez-en une nouvelle.'));
      return;
    }
    if (!list.length) {
      tree.replaceChildren(empty('search', 'Aucune entité ne correspond', 'Changez le référentiel ou la recherche.'));
      return;
    }

    const groups = new Map();
    for (const e of list) {
      if (!groups.has(e.Referentiel)) groups.set(e.Referentiel, []);
      groups.get(e.Referentiel).push(e);
    }

    const cards = [...groups].map(([referential, entities]) => {
      const codes = new Set(entities.map((e) => e.Code));
      const children = new Map();
      const roots = [];
      for (const e of entities) {
        const p = e.ParenteCode;
        if (p && p !== e.Code && codes.has(p)) {
          if (!children.has(p)) children.set(p, []);
          children.get(p).push(e);
        } else roots.push(e);
      }
      const seen = new Set();
      const nodes = roots.map((e) => entityNode(e, children, seen));
      // Une boucle de parenté ne doit pas faire disparaître d'entité.
      for (const e of entities) if (!seen.has(e.Code)) nodes.push(entityNode(e, children, seen));

      const toGenerate = entities.filter((e) => e.state === 'a_engendrer').length;
      // Découper (E42) : une entité seule, sans migration ni condition.
      const [single] = entities;
      const splittable = entities.length === 1 && !single.Split && single.state === 'a_engendrer'
        && single.attributes.some((a) => a.Role === 'aucun');
      return el('section', { className: 'sdm-card sdm-group', dataset: { section: refSection(referential) } },
        el('header', { className: 'sdm-group-head' },
          el('h5', { className: 'sdm-group-name', title: referential }, refName(referential)),
          dot(plural(entities.length, 'entité', 'entités')),
          toGenerate ? stateDot('a_engendrer', `${toGenerate} à engendrer`) : null,
          el('div', { className: 'sdm-actions' },
            splittable && right('write') ? el('button', {
              type: 'button',
              className: 'btn btn-sm btn-outline-secondary',
              title: 'Une entité par valeur d\'une colonne (TYPE…), chaînées en parenté',
              onclick: () => splitDialog(single),
            }, icon('split'), 'Découper') : null,
            right('generate') ? el('button', {
              type: 'button',
              className: `btn btn-sm ${toGenerate ? 'btn-primary' : 'btn-outline-secondary'}`,
              dataset: { generate: referential },
              onclick: generate(referential),
            }, icon('code'), 'Engendrer la migration') : null)),
        el('ul', { className: 'sdm-nodes' }, ...nodes));
    });

    // Rangées par section Standards, repliables (TH.11) ; sans section, à plat.
    const sections = new Map();
    for (const c of cards) {
      const s = c.dataset.section;
      if (!sections.has(s)) sections.set(s, []);
      sections.get(s).push(c);
    }
    if ([...sections.keys()].every((s) => !s)) {
      tree.replaceChildren(...cards);
      return;
    }
    const ordre = [...sections.keys()].sort((a, b) => (!a) - (!b) || a.localeCompare(b));
    fill(tree, ordre.map((s) => el('details', { className: 'sdm-section', open: true },
      el('summary', { className: 'sdm-section-head' }, el('strong', {}, s || 'Sans section'),
        dot(plural(sections.get(s).length, 'référentiel', 'référentiels'))),
      sections.get(s))));
  }

  // ── Découpage (E42) ────────────────────────────────────────────────────

  const slug = (v) => String(v).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');

  /** Une ligne du découpage : valeur, code et libellés de l'entité. */
  function splitRow(entity, p = {}) {
    const input = (name, value, attrs = {}) => el('input', {
      className: `form-control form-control-sm${name === 'code' || name === 'value' ? ' sdm-code' : ''}`,
      name, value: value ?? '', required: name === 'value' || name === 'code', spellcheck: false, 'aria-label': name, ...attrs,
    });
    const tr = el('tr', {},
      el('td', {}, input('value', p.value)),
      el('td', {}, input('code', p.code ?? (p.value ? `${entity.Code}_${slug(p.value)}` : ''), { pattern: '[a-z][a-z0-9_]{0,63}' })),
      el('td', {}, input('fr', p.label?.fr ?? p.value)),
      el('td', {}, input('en', p.label?.en ?? p.value)),
      el('td', { className: 'text-right text-nowrap' },
        iconButton('up', 'Monter (parent)', () => tr.previousElementSibling?.before(tr)),
        iconButton('down', 'Descendre (enfant)', () => tr.nextElementSibling?.after(tr)),
        iconButton('trash', 'Retirer', () => tr.remove(), 'sdm-danger')));
    return tr;
  }

  /**
   * Fenêtre « Découper » : la colonne, puis une entité par valeur. L'ordre des
   * lignes est la chaîne de parenté, la première étant la racine.
   */
  function splitDialog(entity) {
    const columns = entity.attributes.filter((a) => a.Role === 'aucun');
    const column = el('select', { className: 'form-control form-control-sm', name: 'column', 'aria-label': 'Colonne' },
      ...columns.map((a) => el('option', { value: a.Nom, selected: a.Nom.toUpperCase() === 'TYPE' }, a.Nom)));
    const body = el('tbody');
    const hint = el('p', { className: 'small' });
    const loadValues = async () => {
      body.replaceChildren();
      hint.textContent = 'Lecture des valeurs dans Standards…';
      try {
        const { values } = await request('GET', `${API}/entities/${encodeURIComponent(entity.Code)}/split?column=${encodeURIComponent(column.value)}`);
        const found = Object.keys(values);
        body.append(...found.map((v) => splitRow(entity, { value: v })));
        hint.textContent = found.length
          ? `${found.length} valeur(s) trouvée(s) dans la version active. Rangez-les du parent à l'enfant.`
          : 'Aucune version active dans Standards : saisissez les valeurs, du parent à l\'enfant.';
      } catch (err) {
        hint.textContent = err.message;
      }
      if (!body.rows.length) body.append(splitRow(entity), splitRow(entity));
    };
    column.addEventListener('change', loadValues);

    const submit = el('button', { type: 'submit', className: 'btn btn-sm btn-primary' }, icon('split'), 'Découper');
    const dialog = el('dialog', { className: 'sdm-dialog sdm-dialog-wide', 'aria-labelledby': 'sdmSplitTitle' },
      el('form', { method: 'dialog' },
        el('div', { className: 'sdm-dialog-body' },
          el('h4', { id: 'sdmSplitTitle' }, `Découper ${entity.Code}`),
          el('p', {}, 'Une entité par valeur de la colonne. Chacune garde le code, le libellé et le parent ; les autres colonnes vont où Standards a des valeurs, sinon sur toutes.'),
          el('div', { className: 'form-group mt-3' }, el('label', { className: 'small' }, 'Colonne'), column),
          el('table', { className: 'table table-sm sdm-table' },
            el('thead', {}, el('tr', {}, ...['Valeur', 'Code entité', 'Libellé FR', 'Libellé EN', ''].map((t) => el('th', {}, t)))),
            body),
          el('button', { type: 'button', className: 'btn btn-link btn-sm', onclick: () => body.append(splitRow(entity)) }, icon('plus'), 'Ajouter une valeur'),
          hint),
        el('div', { className: 'sdm-drawer-footer' },
          el('button', { type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: () => dialog.close() }, 'Annuler'),
          submit)));
    dialog.querySelector('form').addEventListener('submit', (ev) => {
      ev.preventDefault();
      if (!ev.target.reportValidity()) return;
      const parts = [...body.rows].map((tr) => {
        const v = (name) => tr.querySelector(`[name="${name}"]`).value.trim();
        return { value: v('value'), code: v('code'), label: { fr: v('fr'), en: v('en') } };
      });
      busy(submit, async () => {
        const r = await request('POST', `${API}/entities/${encodeURIComponent(entity.Code)}/split`, { column: column.value, parts });
        notify('success', `${entity.Code} découpée en ${r.entities.map((e) => e.Code).join(', ')}${r.defaultPlacement.length
          ? ` — sans mesure, sur toutes : ${r.defaultPlacement.join(', ')}` : ''}.`);
        dialog.close();
        await loadEntities();
      });
    });
    dialog.addEventListener('close', () => dialog.remove());
    (document.querySelector('.sdm-page') || document.body).append(dialog);
    dialog.showModal();
    loadValues();
  }

  // ── Éditeur ────────────────────────────────────────────────────────────

  const form = () => $('#sdmFormEntity');

  function parentOptions(referential, code) {
    const select = form().elements.ParenteCode;
    const candidates = state.entities.filter((e) => e.Referentiel === referential && e.Code !== code);
    select.replaceChildren(
      el('option', { value: '' }, '— aucune —'),
      ...(code ? [el('option', { value: code }, 'elle-même (hiérarchie interne)')] : []),
      ...candidates.map((e) => el('option', { value: e.Code }, `${e.Code} — ${label(e.Libelle)}`)),
    );
  }

  function edit(code) {
    const e = code ? state.entities.find((x) => x.Code === code) : null;
    state.editing = e ? e.Code : null;
    const f = form();
    const refs = knownReferentials();
    fill(f.elements.Referentiel, refOptions(refs));

    f.elements.Code.value = e ? e.Code : '';
    f.elements.Code.readOnly = Boolean(e);
    f.elements.Referentiel.value = e ? e.Referentiel : ($('#sdmFilterRef').value || refs[0] || '');
    f.elements.Referentiel.disabled = Boolean(e);
    f.elements.LibelleFr.value = e?.Libelle?.fr || '';
    f.elements.LibelleEn.value = e?.Libelle?.en || '';
    f.elements.Ordre.value = e ? e.Ordre : 0;
    // La section est celle du référentiel (sa fiche SDM_RF), avec les sections existantes en suggestion.
    f.elements.Section.value = refSection(f.elements.Referentiel.value);
    fill($('#sdmSections'), [...new Set(state.referentials.map((r) => r.section).filter(Boolean))].sort()
      .map((s) => el('option', { value: s })));
    f.elements.Actif.checked = e ? e.Actif : true;
    parentOptions(f.elements.Referentiel.value, e?.Code);
    f.elements.ParenteCode.value = e?.ParenteCode || '';
    f.elements.ParenteCode.disabled = e?.state === 'creee';
    f.elements.ParenteCode.title = e?.state === 'creee' ? 'Les tables existent : la parenté est figée' : '';

    $('#sdmEditorKicker').replaceChildren(e ? `Entité · ${e.Referentiel} · ` : 'Entité', e ? stateDot(e.state, ENTITY_STATES[e.state] || e.state) : '');
    $('#sdmEditorTitle').textContent = e ? e.Code : 'Nouvelle entité';
    $('#sdmBlockAttributes').classList.toggle('d-none', !e);
    $('#sdmTableAttributes tbody').replaceChildren(...(e ? e.attributes.map(attributeRow) : []));

    const read = !right('write');
    f.querySelectorAll('input, select').forEach((n) => { if (read) n.disabled = true; });
    openDrawer('#sdmEditor');
    setTimeout(() => (e ? f.elements.LibelleFr : f.elements.Code).focus({ preventScroll: true }), 50);
  }

  function select(name, values, value) {
    return el('select', { className: 'form-control form-control-sm', name: name, 'aria-label': name },
      ...values.map((v) => el('option', { value: v, selected: v === value }, v)));
  }

  let number = 0;

  /** Interrupteur Bootstrap ; renvoie [conteneur, case]. */
  function toggle(name, checked, text) {
    const id = `sdmInter${++number}`;
    const checkbox = el('input', { type: 'checkbox', className: 'custom-control-input', id, name: name, checked: checked });
    return [el('div', { className: 'custom-control custom-switch' }, checkbox,
      el('label', { className: 'custom-control-label', htmlFor: id }, el('span', { className: 'sr-only' }, text))), checkbox];
  }

  function iconButton(name, title, onclick, cls = '') {
    return el('button', { type: 'button', className: `btn btn-link btn-sm btn-icone ${cls}`.trim(), title: title, 'aria-label': title, onclick }, icon(name));
  }

  function attributeRow(a = {}) {
    const read = !right('write');
    const [multiBlock, multi] = toggle('Multivalue', Boolean(a.Multivalue), 'Multivalué');
    const [fingerprintBlock] = toggle('DansEmpreinte', a.DansEmpreinte !== false, 'Dans l\'empreinte');
    const sep = el('input', {
      className: 'form-control form-control-sm', name: 'Separateur', value: a.Separateur ?? '', size: 3,
      disabled: !a.Multivalue, 'aria-label': 'Séparateur',
    });
    multi.addEventListener('change', () => { sep.disabled = !multi.checked; if (!multi.checked) sep.value = ''; });
    const role = select('Role', state.roles, a.Role || 'aucun');
    const tint = () => { role.className = `form-control form-control-sm sdm-role sdm-role-${role.value}`; };
    role.addEventListener('change', tint);
    tint();
    const tr = el('tr', {},
      el('td', {}, el('input', { className: 'form-control form-control-sm sdm-code', name: 'Nom', value: a.Nom || '', 'aria-label': 'Nom', spellcheck: false })),
      el('td', {}, select('Type', state.types, a.Type || 'texte')),
      el('td', {}, role),
      el('td', {}, multiBlock),
      el('td', {}, sep),
      el('td', {}, fingerprintBlock),
      el('td', {}, el('input', { className: 'form-control form-control-sm', name: 'LibelleFr', value: a.Libelle?.fr || '', 'aria-label': 'Libellé FR' })),
      el('td', {}, el('input', { className: 'form-control form-control-sm', name: 'LibelleEn', value: a.Libelle?.en || '', 'aria-label': 'Libellé EN' })),
      el('td', {}, el('input', {
        className: 'form-control form-control-sm', name: 'LongueurFixe', type: 'number', min: 1, step: 1,
        value: a.LongueurFixe ?? '', 'aria-label': 'Longueur fixe',
      })),
      el('td', {}, select('Syndication', state.syndications, a.Syndication || 'none')),
      el('td', { className: 'text-right text-nowrap' },
        iconButton('up', 'Monter', () => tr.previousElementSibling?.before(tr)),
        iconButton('down', 'Descendre', () => tr.nextElementSibling?.after(tr)),
        iconButton('trash', 'Retirer', () => tr.remove(), 'sdm-danger')),
    );
    if (read) tr.querySelectorAll('input, select, button').forEach((n) => { n.disabled = true; });
    return tr;
  }

  function readAttributes() {
    return [...$('#sdmTableAttributes tbody').rows].map((tr) => {
      const v = (name) => tr.querySelector(`[name="${name}"]`);
      return {
        Nom: v('Nom').value.trim(),
        Type: v('Type').value,
        Role: v('Role').value,
        Multivalue: v('Multivalue').checked,
        Separateur: v('Multivalue').checked ? v('Separateur').value : null,
        DansEmpreinte: v('DansEmpreinte').checked,
        Libelle: { fr: v('LibelleFr').value.trim(), en: v('LibelleEn').value.trim() },
        LongueurFixe: v('LongueurFixe').value === '' ? null : Number(v('LongueurFixe').value),
        Syndication: v('Syndication').value,
      };
    });
  }

  async function save() {
    const f = form();
    if (!f.reportValidity()) return;
    const entity = {
      Libelle: { fr: f.elements.LibelleFr.value.trim(), en: f.elements.LibelleEn.value.trim() },
      ParenteCode: f.elements.ParenteCode.value || null,
      Ordre: Number(f.elements.Ordre.value) || 0,
      Actif: f.elements.Actif.checked,
      Section: f.elements.Section.value.trim(),
    };
    let code = state.editing;
    if (!code) {
      const created = await request('POST', `${API}/entities`, {
        ...entity, Code: f.elements.Code.value.trim(), Referentiel: f.elements.Referentiel.value,
      });
      code = created.Code;
      notify('success', `Entité ${code} créée : ajoutez ses attributs.`);
    } else {
      const before = state.entities.find((x) => x.Code === code);
      if (before.state === 'creee') delete entity.ParenteCode;
      await request('PUT', `${API}/entities/${encodeURIComponent(code)}`, entity);
      await request('PUT', `${API}/entities/${encodeURIComponent(code)}/attributes`, { attributes: readAttributes() });
      notify('success', `Entité ${code} enregistrée.`);
    }
    await loadEntities();
    edit(code);
  }

  // ── Reprise et migrations ──────────────────────────────────────────────

  function showFindings(r) {
    const c = r.findings;
    const rows = [
      ['Entités ajoutées', r.added],
      ['Déjà déclarées (non touchées)', r.ignored],
      ['Découpés d\'office', c.splitDone || []],
      ['Types non déclarés → texte', c.defaultTypes.map((x) => `${x.referential}.${x.column}`)],
      ['Séparateur « bracketed » → « , » (crochets non retirés)', c.closeSeparators.map((x) => `${x.referential}.${x.column}`)],
      ['Séparateurs inconnus', c.unknownSeparators.map((x) => `${x.referential}.${x.column} (${x.separator})`)],
      ['Type non déclaré : séparateur ignoré par Standards → retour à la ligne', (c.ignoredSeparators || []).map((x) => `${x.referential}.${x.column} (${x.separator})`)],
      ['Référentiels sans schéma', c.withoutSchema],
    ].filter(([, v]) => v.length);
    const box = $('#sdmFindings');
    box.replaceChildren(
      el('strong', {}, 'Reprise depuis Standards'),
      el('ul', {}, ...rows.map(([t, v]) => el('li', {}, `${t} (${v.length}) : `, el('span', { className: 'sdm-code' }, v.join(', '))))),
    );
    box.classList.remove('d-none');
  }

  // ── Imports ────────────────────────────────────────────────────────────

  // Même écran que l'onglet « Intégrations » de Standards : menu par section à
  // gauche (une seule ouverte à la fois), fiche du référentiel choisi à droite.
  // Le <select> caché du formulaire reste la source du choix : tout le reste le lit.
  let openSection = null;
  let history = [];

  const importRef = () => $('#sdmFormImport').elements.referential.value;

  function pickImport(code) {
    const select = $('#sdmFormImport').elements.referential;
    if (select.value === code) return;
    select.value = code;
    select.dispatchEvent(new Event('change'));
  }

  function drawImportsMenu() {
    const chosen = importRef();
    const bySection = new Map();
    for (const c of state.importable) {
      const s = refSection(c);
      if (!bySection.has(s)) bySection.set(s, []);
      bySection.get(s).push(c);
    }
    // Les sections nommées d'abord, « Sans section » en dernier.
    const sections = [...bySection.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
    const single = sections.length === 1;
    fill($('#sdmMenuImports'), state.importable.length ? sections.map((s, i) => {
      const open = single || openSection === s;
      const id = `sdmMenuSection${i}`;
      return el('div', { className: `sdm-integ-section${open ? ' ouverte' : ''}` },
        single && !s ? null : el('button', {
          type: 'button', className: 'sdm-integ-section-title', 'aria-expanded': String(open), 'aria-controls': id,
          onclick: () => { openSection = openSection === s ? null : s; drawImportsMenu(); },
        }, el('span', { className: 'sdm-integ-chevron', 'aria-hidden': 'true' }),
        el('span', { className: 'sdm-integ-section-name' }, s || 'Sans section'),
        el('span', { className: 'sdm-integ-count' }, bySection.get(s).length)),
        el('ul', { className: 'sdm-integ-list', id, hidden: !open }, bySection.get(s).map((c) => {
          const name = label(refSheet(c)?.name);
          return el('li', {}, el('button', {
            type: 'button', className: `sdm-integ-item${c === chosen ? ' courant' : ''}`,
            ...(c === chosen ? { 'aria-current': 'true' } : {}), onclick: () => pickImport(c),
          }, el('span', { className: 'sdm-integ-code' }, c), name && name !== c ? el('span', { className: 'sdm-integ-name' }, name) : null));
        })));
    }) : el('p', { className: 'sdm-integ-empty' }, 'Aucun référentiel importable : déclarez ses entités, puis jouez sa migration.'));
  }

  /** La fiche de droite suit le choix ; un rapport ou une reprise d'un autre référentiel s'efface. */
  function showImportSheet() {
    const code = importRef();
    $('#sdmSheetImportEmpty').classList.toggle('d-none', Boolean(code));
    $('#sdmSheetImport').classList.toggle('d-none', !code);
    if (code) {
      openSection = refSection(code);
      const name = label(refSheet(code)?.name);
      $('#sdmSheetImportCode').textContent = code;
      $('#sdmSheetImportName').textContent = name && name !== code ? name : '';
    }
    for (const id of ['#sdmReport', '#sdmBackfillData']) {
      if ($(id).dataset.referential !== code) $(id).classList.add('d-none');
    }
    drawImportsMenu();
    showHistory();
  }

  async function loadImports() {
    // La liste suit les déclarations (une reprise en ajoute) ; le choix est gardé.
    const select = $('#sdmFormImport').elements.referential;
    const chosen = select.value;
    if ([...select.options].map((o) => o.value).filter(Boolean).join() !== state.importable.join()) {
      fill(select, el('option', { value: '' }), state.importable.map((c) => el('option', { value: c }, c)));
      select.value = state.importable.includes(chosen) ? chosen : '';
      if (select.value) loadHelp().catch(() => {});
    }
    showImportSheet();
    updateButtons();
    skeleton('#sdmTableImports tbody', 8);
    history = await request('GET', `${API}/imports`);
    summary.imports = history;
    showPath();
    showHistory();
  }

  /** L'historique du seul référentiel choisi. */
  function showHistory() {
    const code = importRef();
    const imports = history.filter((i) => i.SDM_IM_Referential === code);
    $('#sdmNbImports').textContent = imports.length;
    fillTable('#sdmCardImports', '#sdmTableImports tbody', imports.map((i) => el('tr', {},
      el('td', { className: 'text-nowrap' }, date(i.SDM_IM_AnalyzedAt)),
      el('td', {}, dot(i.SDM_IM_VersionNum, 'sdm-dot-accent')),
      el('td', { className: 'sdm-code' }, i.SDM_IM_File),
      el('td', {}, stateDot(i.SDM_IM_State, IMPORT_STATES[i.SDM_IM_State] || i.SDM_IM_State)),
      el('td', { className: 'sdm-number' }, i.SDM_IM_State === 'integre' ? i.SDM_IM_CreatedItems : ''),
      el('td', { className: 'sdm-number' }, i.SDM_IM_State === 'integre' ? i.SDM_IM_CreatedContents : ''),
      el('td', { className: 'sdm-number' }, i.SDM_IM_State === 'integre' ? i.SDM_IM_ReusedContents : ''),
      el('td', { className: 'text-right' }, el('button', {
        type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: (ev) => busy(ev.currentTarget, () => viewImport(i.SDM_IM_Id)),
      }, icon('eye'), 'Rapport')),
    )), '#sdmImportsEmpty', ['imports', 'Aucun import', `Aucune version de ${code} analysée : déposez ses fichiers ci-dessus, ou reprenez les versions Standards.`]);
  }

  async function viewImport(id) {
    const i = await request('GET', `${API}/imports/${id}`);
    showReport({
      id: i.SDM_IM_Id, state: i.SDM_IM_State, referential: i.SDM_IM_Referential,
      versionNum: i.SDM_IM_VersionNum, report: i.SDM_IM_Report, stats: i.SDM_IM_Report.stats,
    });
  }

  /** Une liste du rapport, repliable ; `alerte` la signale en rouge. */
  function detail(title, list, format, alert = false) {
    if (!list || !list.length) return null;
    const rows = list.slice(0, 500).map(format).join('\n') + (list.length > 500 ? `\n… et ${list.length - 500} autres` : '');
    return el('details', { className: alert ? 'sdm-alert' : '' },
      el('summary', {}, alert ? icon('alert') : null, `${title} (${list.length})`), el('pre', {}, rows));
  }

  /** La phrase qui résume un rapport, en clair (TH.4). */
  function reportSentence(r, versionNum) {
    const total = (r.newMembers || 0) + (r.rewrittenMembers || 0) + (r.unchangedMembers || 0);
    const pieces = [`La version ${versionNum} compte ${plural(total, 'code', 'codes')}`];
    if (r.createdIdentities) pieces.push(`dont ${plural(r.createdIdentities, 'nouveau', 'nouveaux')} jamais vu${r.createdIdentities > 1 ? 's' : ''}`);
    const texts = `${plural(r.createdContents || 0, 'texte nouveau ou modifié', 'textes nouveaux ou modifiés')}, ${r.reusedContents || 0} déjà connu${r.reusedContents > 1 ? 's' : ''}`;
    return `${pieces.join(', ')} ; ${texts}.`;
  }

  /** `liensNatifs` est un compte par table de liens ({ table: n }) ; un ancien rapport peut porter un nombre. */
  const totalLinks = (v) => (v && typeof v === 'object' ? Object.values(v).reduce((a, n) => a + (Number(n) || 0), 0) : (v || 0));
  const linkDetails = (v) => (v && typeof v === 'object' ? Object.entries(v).map(([t, n]) => `${t} : ${n}`).join('\n') : '');

  function showReport({ id, state: importState, referential, versionNum, report: r, stats, gaps }) {
    const figure = (n, t, alert = false, title = '') => el('div', { className: `sdm-figure${alert && n ? ' sdm-figure-alert' : ''}`, title }, el('b', {}, n ?? 0), el('span', {}, t));
    const box = $('#sdmReport');
    const queued = importState === 'analyse';
    const perime = importState === 'perime';
    const dead = r.deadLinkCount || 0;
    const removed = (r.removedMembers || []).length;
    // Le réimport d'une version déjà présente : seulement s'il y a quelque chose à en dire.
    const reimport = (r.rewrittenMembers || 0) + (r.unchangedMembers || 0) + removed > 0;
    box.className = 'sdm-card sdm-report';
    box.dataset.referential = referential;
    fill(box,
      el('div', { className: 'sdm-report-head' },
        el('div', { className: 'sdm-report-titles' },
          el('span', { className: 'sdm-report-kicker' }, "Rapport d'analyse"),
          el('h5', { className: 'sdm-report-title' },
            el('span', {}, label(refSheet(referential)?.name) || referential),
            el('span', { className: 'sdm-report-pill sdm-code' }, referential),
            el('span', { className: 'sdm-report-pill' }, `v${versionNum}`),
            stateDot(importState, IMPORT_STATES[importState] || importState))),
        el('button', { type: 'button', className: 'sdm-close', 'aria-label': 'Fermer le rapport', onclick: () => box.classList.add('d-none') }, icon('close'))),
      el('p', { className: 'sdm-report-sentence' }, reportSentence(r, versionNum)),
      gaps && gaps.length ? el('p', { className: 'sdm-help' }, `Depuis l'analyse précédente : ${gaps.join(' ; ')}.`) : null,
      perime ? el('div', { className: 'sdm-inset sdm-alert' },
        icon('alert'), ' Analyse périmée : une autre version a été intégrée depuis. Relancez l\'analyse avant de valider.') : null,
      el('div', { className: 'sdm-report-sub' }, 'Contenu'),
      el('div', { className: 'sdm-figures' },
        figure(r.createdIdentities, 'nouveaux codes', false, 'Codes jamais vus dans une version précédente'),
        figure(r.createdContents, 'textes nouveaux ou modifiés', false, 'Libellés et données enregistrés pour la première fois'),
        figure(r.reusedContents, 'textes déjà connus', false, 'Identiques à une version précédente : réutilisés, pas recopiés'),
        figure(r.newMembers, 'codes ajoutés à la version'),
        stats ? figure(stats.source, 'lignes lues') : null,
      ),
      reimport ? [
        el('div', { className: 'sdm-report-sub' }, 'Réimport de la version'),
        el('p', { className: 'sdm-help' }, 'Cette version existe déjà : voici ce que le nouvel import y change.'),
        el('div', { className: 'sdm-figures' },
          figure(r.unchangedMembers, 'codes inchangés'),
          figure(r.rewrittenMembers, 'codes réécrits', false, 'Même code, contenu ou parent différent'),
          figure(removed, 'codes retirés de la version', true)),
      ] : null,
      detail('Codes réapparus', r.reappeared, (x) => `${x.entity} ${x.code} : absent de ${x.missingFrom}, vu en ${x.lastPresence}`, true),
      detail('Changements de niveau', r.levelChanges, (x) => `${x.code} : ${x.before} → ${x.after} (depuis ${x.version})`, true),
      detail('Retirés de la version', r.removedMembers, (x) => `${x.entity} ${x.code}`, true),
      detail('Parents introuvables', r.missingParents, (x) => `${x.entity} ${x.code} → ${x.parentEntity} ${x.parentCode}`, true),
      detail('Types inconnus', r.unknownTypes, (x) => `${x.code} : ${x.value}`, true),
      detail('Doublons', r.duplicates, (x) => `${x.code}${x.lang ? ` (${x.lang})` : ''}`),
      detail('Monolingues', r.monolingual, (x) => `${x.code} : ${x.lang} seulement`),
      detail('Divergences FR/EN (FR retenu)', r.divergences, (x) => `${x.code}.${x.column} : « ${x.fr} » / « ${x.en} »`),
      r.withoutCode ? el('p', { className: 'sdm-help' }, `${r.withoutCode} ligne(s) sans code ignorée(s).`) : null,
      // Contrôle de structure (T2.2.13) : ces valeurs ne seront pas importées, ces colonnes resteront vides.
      detail('Colonnes du fichier non déclarées — valeurs ignorées', r.undeclaredColumns,
        (x) => `${x.entity}.${x.attribute} : ${plural(x.nb, 'ligne', 'lignes')}`, true),
      detail('Colonnes déclarées absentes du fichier', r.missingColumns, (x) => `${x.entity}.${x.attribute}`, true),
      // Les alertes du traitement Standards (TH.12) : à corriger dans le classeur source.
      detail('Questions désalignées FR/EN — à corriger dans le classeur', r.alerts?.misalignedRows,
        (x) => `${x.sheet ? `[${x.sheet}] ` : ''}${x.code} : « ${x.fr ?? '—'} » / « ${x.en ?? '—'} »`, true),
      r.alerts?.misalignedCols ? el('div', { className: 'sdm-inset sdm-alert' }, icon('alert'),
        ` Colonnes désalignées : FR « ${r.alerts.misalignedCols.fr} » / EN « ${r.alerts.misalignedCols.en} ».`) : null,
      r.alerts?.remarks?.length ? el('ul', { className: 'sdm-help' }, r.alerts.remarks.map((t) => el('li', {}, t))) : null,
      r.nativeLinks !== undefined ? [
        el('div', { className: 'sdm-report-sub' }, 'Liens'),
        el('div', { className: 'sdm-figures' },
          figure(totalLinks(r.nativeLinks), 'liens natifs', false, linkDetails(r.nativeLinks)),
          figure(dead, 'liens vers un code absent', true),
          figure(r.renewedLinks, 'liens déclarés reconduits'),
          figure((r.linksToConfirm || []).length, 'liens à confirmer', true),
          figure((r.staleLinks || []).length, 'liens caducs'),
        ),
      ] : null,
      detail('Liens vers un code absent — l\'intégration sera refusée', r.deadLinks, (x) => `${x.source}.${x.attribute} → ${x.target} ${x.code}`, true),
      detail('Liens à confirmer après intégration', r.linksToConfirm, (x) => `${x.link} : ${x.source} → ${x.target} (${x.reason})`, true),
      detail('Liens caducs', r.staleLinks, (x) => `${x.link} : ${x.source || '?'} → ${x.target || '?'} (${x.reason})`),
      (queued || perime) && right('validate') ? el('div', { className: 'sdm-report-decision' },
        el('p', { className: 'sdm-report-decision-text' }, !droppedFiles(referential)
          ? 'Les fichiers ne sont pas gardés sur le serveur : redéposez les fichiers analysés ci-dessus pour valider ou relancer.'
          : perime
          ? 'La base a changé depuis cette analyse : relancez-la avec les fichiers déposés.'
          : dead
            ? 'Des liens pointent vers un code absent : l\'intégration sera refusée tant qu\'ils ne sont pas corrigés.'
            : 'Rien n\'est encore écrit. Validez pour intégrer cette version, ou refusez-la.'),
        el('div', { className: 'sdm-report-decision-buttons' },
          el('button', { type: 'button', className: 'btn btn-sm btn-outline-danger', onclick: (ev) => busy(ev.currentTarget, () => refuse(id)) }, icon('close'), 'Refuser'),
          el('button', { type: 'button', className: 'btn btn-sm btn-outline-secondary', onclick: (ev) => busy(ev.currentTarget, () => rerun(id, referential, ev.currentTarget)) }, icon('refresh'), 'Relancer l\'analyse'),
          queued ? el('button', { type: 'button', className: 'btn btn-sm btn-success sdm-report-validate', onclick: (ev) => busy(ev.currentTarget, () => integrate(id, referential, versionNum, ev.currentTarget)) }, icon('validate'), 'Valider et intégrer') : null),
      ) : null,
    );
    box.classList.remove('d-none');
    box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }

  /** Les fichiers déposés dans les zones, si elles en portent pour ce référentiel. */
  function droppedFiles(referential) {
    const form = $('#sdmFormImport');
    if (form.elements.referential.value !== referential) return null;
    const records = new FormData(form);
    const dropped = ['fileFr', 'fileEn', 'file'].some((n) => form.elements[n]?.files?.[0]);
    return dropped ? records : null;
  }

  /**
   * Rien n'est gardé sur le serveur entre l'analyse et l'intégration : on
   * renvoie les fichiers des zones ; le serveur vérifie que ce sont les mêmes.
   */
  function filesToResend(referential) {
    const records = droppedFiles(referential);
    if (!records) notify('warning', 'Redéposez les fichiers analysés dans les zones de dépôt : ils ne sont pas gardés sur le serveur.');
    return records;
  }

  async function integrate(id, referential, versionNum, button) {
    const records = filesToResend(referential);
    if (!records) return;
    const ok = await askConfirmation({
      title: `Intégrer la version ${versionNum} ?`,
      text: 'Cette action écrit dans les données. Les autres analyses en attente du référentiel deviendront périmées.',
      validate: 'Intégrer',
    });
    if (!ok) return;
    await runTask(button, 'POST', `${API}/imports/${id}/integrate`, records);
    notify('success', `Version ${versionNum} intégrée.`);
    clearImportInput();
    await viewImport(id);
    await loadImports();
    loadConfirm().catch(() => {});
  }

  /** Refait l'analyse avec les fichiers redéposés dans les zones (TH.4). */
  async function rerun(id, referential, button) {
    const records = filesToResend(referential);
    if (!records) return;
    const r = await runTask(button, 'POST', `${API}/imports/${id}/rerun`, records);
    const i = await request('GET', `${API}/imports/${id}`);
    showReport({
      id, state: i.SDM_IM_State, referential: i.SDM_IM_Referential, versionNum: r.versionNum, report: r.report, stats: r.stats, gaps: r.gaps,
    });
    notify('success', r.gaps.length ? 'Analyse relancée : les chiffres ont changé.' : 'Analyse relancée : rien n\'a changé.');
    await loadImports();
  }

  /**
   * L'aide de l'import (TH.12) : versions présentes, version proposée,
   * dépendances absentes, fiche Standards et fichiers attendus.
   */
  async function loadHelp() {
    const referential = $('#sdmFormImport').elements.referential.value;
    const box = $('#sdmHelpImport');
    if (!referential) { box.classList.add('d-none'); $('#sdmSheetSource').replaceChildren(); return; }
    const a = await request('GET', `${API}/imports/help/${encodeURIComponent(referential)}`);
    // Jamais pré-remplie : une version oubliée deviendrait « 1 » sans qu'on l'ait choisie.
    // La proposée n'est qu'un exemple grisé ; changer de référentiel vide la saisie.
    const versionField = $('#sdmFormImport').elements.versionNum;
    if (versionField.dataset.referential !== referential) versionField.value = '';
    versionField.dataset.referential = referential;
    versionField.placeholder = `ex. ${a.proposedVersion}`;
    const f = a.sheet;
    const active = a.versions.find((v) => String(v.version) === String(a.activeVersion));
    // Comme Standards : la version active en repère sous le titre, la source d'extraction au-dessus du traitement.
    fill($('#sdmSheetSource'), sheetBlock(referential, f));
    fill(box,
      el('p', { className: 'sdm-integ-active' }, a.activeVersion
        ? [el('span', { className: 'sdm-integ-active-title' }, 'Version active'), el('strong', {}, a.activeVersion),
          active ? el('span', { className: 'sdm-integ-active-nb' }, plural(active.nb, 'code', 'codes')) : null,
          el('span', { className: 'sdm-integ-active-nb' }, `proposée : ${a.proposedVersion}`)]
        : [el('span', { className: 'sdm-integ-active-title' }, 'Aucune version intégrée'), 'ce sera la première.']),
      a.versions.length > 1 ? el('div', { className: 'sdm-help' },
        `Versions présentes : ${a.versions.map((v) => `${v.version} (${v.nb})`).join(', ')}`) : null,
      a.missingDependencies.length ? el('div', { className: 'sdm-alert' }, icon('alert'),
        ` Dépend de ${a.missingDependencies.join(', ')}, qui n'a encore aucune version : importez-le d'abord.`) : null,
      // Un seul format, l'officiel (T2.3.21) : le fichier source s'il y a un traitement, le plat sinon.
      el('div', { className: 'sdm-help' }, a.format.source
        ? `Déposez ${a.format.expected} (fichier officiel, traitement Standards ${a.format.source}).`
        : ['Format plat : la ligne 1 porte ', el('span', { className: 'sdm-code' }, a.format.flat.join(', ')), '.']));
    box.classList.remove('d-none');
    showSourceDropzones(a.format.source);
    setExtensions(a.format.extensions || {});
  }

  /** Les champs de dépôt par clé de fichier, comme côté serveur (FILES). */
  const FILE_FIELDS = { fr: 'fileFr', en: 'fileEn', source: 'file', l4: 'fileL4', stdFr: 'fileStdFr', stdEn: 'fileStdEn' };

  /** Comme Standards : chaque zone n'accepte que les extensions de son fichier. */
  function setExtensions(extensions) {
    Object.entries(FILE_FIELDS).forEach(([key, name]) => {
      const field = $('#sdmFormImport').elements[name];
      if (!field) return;
      const allowed = extensions[key] || [];
      field.accept = allowed.join(',');
      field.dataset.extensions = allowed.join(',');
      if (field.files[0] && !allowedFile(field)) {
        field.value = '';
        field.dispatchEvent(new Event('change'));
      }
    });
  }

  /** Le fichier choisi a-t-il une extension acceptée ? (sans liste : tout passe) */
  function allowedFile(field) {
    const allowed = (field.dataset.extensions || '').split(',').filter(Boolean);
    const f = field.files[0];
    return !f || !allowed.length || allowed.some((ext) => f.name.toLowerCase().endsWith(ext));
  }

  /**
   * La source d'extraction du référentiel (E28), lue dans sa fiche SDM. Comme
   * Standards, toujours ouverte : les liens et le mode opératoire restent sous
   * les yeux de qui extrait, et « Enregistrer » n'apparaît qu'une fois la fiche
   * touchée. Sans le droit d'écrire, les mêmes champs, en lecture seule.
   */
  function sheetBlock(referential, f) {
    const sheet = f || { sourceUrls: [], procedure: '', internalOnly: false };
    const write = right('write');
    const links = [...sheet.sourceUrls];
    const block = el('div', { className: 'sdm-sheet-source' });
    const footer = el('div', { className: 'sdm-footer d-none' });
    const touched = () => footer.classList.remove('d-none');

    const list = el('ul', { className: 'sdm-sheet-links' });
    const drawLinks = () => fill(list, links.length
      ? links.map((u, i) => el('li', {},
        el('a', { href: u, target: '_blank', rel: 'noopener noreferrer' }, icon('external'), el('span', {}, u)),
        write ? el('button', {
          type: 'button', className: 'sdm-sheet-remove', title: 'Retirer ce lien', 'aria-label': `Retirer ${u}`,
          onclick: () => { links.splice(i, 1); drawLinks(); touched(); },
        }, icon('trash')) : null))
      : el('li', { className: 'sdm-sheet-none' }, 'Aucun lien renseigné.'));

    const fresh = el('input', { type: 'url', className: 'form-control form-control-sm', placeholder: 'https://…' });
    const add = () => {
      const value = fresh.value.trim();
      if (!value) return;
      if (!/^https?:\/\/\S+$/i.test(value)) { notify('alert', 'Lien attendu en http:// ou https://'); return; }
      if (!links.includes(value)) links.push(value);
      fresh.value = '';
      drawLinks();
      touched();
      fresh.focus();
    };
    fresh.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } });

    const internal = el('input', { type: 'checkbox', className: 'custom-control-input', id: 'sdmSheetInternal', disabled: !write });
    internal.checked = sheet.internalOnly;
    internal.addEventListener('change', touched);
    const procedure = el('textarea', { className: 'form-control form-control-sm', rows: 5, maxLength: 20000, readOnly: !write,
      placeholder: write ? 'Les étapes pour obtenir les fichiers : où cliquer, quoi télécharger, quelles options…' : 'Aucun mode opératoire renseigné.' });
    procedure.value = sheet.procedure;
    procedure.addEventListener('input', touched);

    const save = el('button', { type: 'button', className: 'btn btn-primary btn-sm' }, 'Enregistrer');
    save.addEventListener('click', () => busy(save, async () => {
      // Un lien tapé sans cliquer « Ajouter » n'est pas perdu.
      if (fresh.value.trim()) add();
      await request('PUT', `${API}/referentials/${encodeURIComponent(referential)}`, {
        Sources: links, Procedure: procedure.value, InterneSeul: internal.checked,
      });
      notify('success', "Source d'extraction enregistrée.");
      await loadHelp();
    }));
    fill(footer,
      el('button', { type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: () => loadHelp() }, 'Annuler'),
      save);

    drawLinks();
    fill(block,
      el('div', { className: 'sdm-sheet-head' }, el('strong', {}, "Source d'extraction")),
      el('div', { className: 'custom-control custom-switch' }, internal,
        el('label', { className: 'custom-control-label', htmlFor: 'sdmSheetInternal' }, 'Usage interne X-One uniquement')),
      // Un div, pas un label : le label renverrait ses clics vers le bouton « Ajouter ».
      el('div', { className: 'sdm-field' }, el('span', {}, "Lien vers la source d'extraction des données"),
        write ? el('div', { className: 'sdm-sheet-add' }, fresh,
          el('button', { type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: add }, icon('plus'), ' Ajouter')) : null,
        list),
      el('label', { className: 'sdm-field' }, el('span', {}, "Mode opératoire d'extraction des données"), procedure),
      footer);
    return block;
  }

  /** Les zones de l'extraction source : celles de la sorte du traitement, vidées sinon. */
  function showSourceDropzones(kind) {
    const block = $('#sdmDropzonesSource');
    const toggle = (zone, shown) => {
      zone.classList.toggle('d-none', !shown);
      if (!shown && zone.querySelector('input').value) {
        zone.querySelector('input').value = '';
        zone.querySelector('input').dispatchEvent(new Event('change'));
      }
    };
    // Comme Standards : un traitement à un fichier (CSV, classeur…) ne montre pas les zones FR et EN.
    const byLang = !kind || kind === 'bilingual';
    $('#sdmDropzonesLang').classList.toggle('d-none', !byLang);
    $('#sdmDropzonesLang').querySelectorAll('.sdm-dropzone').forEach((zone) => toggle(zone, byLang));
    let visible = false;
    block.querySelectorAll('.sdm-dropzone').forEach((zone) => {
      const shown = Boolean(kind) && zone.dataset.kind.split(' ').includes(kind);
      toggle(zone, shown);
      visible = visible || shown;
    });
    block.querySelector('[data-label-source]').textContent = kind === 'risks' ? 'Niveaux 1 à 3'
      : kind === 'csv' ? 'Extraction CSV' : 'Source';
    block.classList.toggle('d-none', !visible);
  }

  async function refuse(id) {
    await request('POST', `${API}/imports/${id}/refuse`);
    notify('info', 'Import refusé.');
    await viewImport(id);
    await loadImports();
  }

  /** Après une intégration ou un changement de référentiel : zones de dépôt et version vidées. */
  function clearImportInput() {
    const form = $('#sdmFormImport');
    form.querySelectorAll('.sdm-dropzone input[type=file]').forEach((field) => {
      if (!field.value) return;
      field.value = '';
      field.dispatchEvent(new Event('change'));
    });
    form.elements.versionNum.value = '';
  }

  /** Zones de dépôt : nom et taille du fichier choisi, surbrillance au survol. */
  function wireDropzones() {
    document.querySelectorAll('.sdm-dropzone').forEach((zone) => {
      const field = zone.querySelector('input[type=file]');
      const name = zone.querySelector('.sdm-dropzone-name');
      const prompt = name.textContent;
      const show = () => {
        if (!allowedFile(field)) {
          notify('alert', `${field.files[0].name} : seuls les fichiers ${field.dataset.extensions.split(',').join(' ou ')} sont acceptés ici.`);
          field.value = '';
        }
        const f = field.files[0];
        zone.classList.toggle('charge', Boolean(f));
        name.textContent = f ? `${f.name} · ${size(f.size)}` : prompt;
      };
      field.addEventListener('change', show);
      ['dragenter', 'dragover'].forEach((t) => field.addEventListener(t, () => zone.classList.add('survol')));
      ['dragleave', 'drop'].forEach((t) => field.addEventListener(t, () => zone.classList.remove('survol')));
    });
  }

  // ── Reprise des versions Standards ─────────────────────────────────────

  async function dataBackfill() {
    const referential = $('#sdmFormImport').elements.referential.value;
    if (!referential) return;
    const ok = await askConfirmation({
      title: 'Reprendre les versions Standards ?',
      text: `Toutes les versions Standards de ${refName(referential)} sont rejouées dans les trios, de la plus ancienne à la plus récente. Cette action écrit dans les données.`,
      validate: 'Reprendre',
    });
    if (!ok) return;
    const r = await request('POST', `${API}/backfill/data`, { referential });
    const STATE = { backfill: 'reprise', deja_presente: 'déjà présente' };
    const box = $('#sdmBackfillData');
    box.dataset.referential = referential;
    box.replaceChildren(
      el('strong', {}, `Reprise des données ${referential}`),
      el('ul', {}, ...r.map((v) => el('li', {},
        el('strong', {}, v.versionNum), ` : ${STATE[v.state] || v.state}`,
        v.state === 'backfill' ? ` — ${v.backfilledCodes} codes repris / ${v.codesStandards} dans Standards `
          + `(${v.matching ? 'concordant' : 'ÉCART'}), ${v.createdIdentities} items créés, `
          + `${v.createdContents} contenus créés, ${v.reusedContents} réutilisés`
          + (v.truncatedLabels ? `, ${v.truncatedLabels} libellé(s) tronqué(s) par Standards` : '') : '',
      ))),
    );
    box.classList.remove('d-none');
    await loadImports();
  }

  // ── Migrations ─────────────────────────────────────────────────────────

  function migrationObject(plan) {
    if (plan.type === 'entities') return `${plan.referential} : ${plan.entities.join(', ')}`;
    if (plan.type === 'link') return `lien ${plan.source}.${plan.attribute} → ${plan.target} (${plan.table})`;
    if (plan.type === 'view') return `vue ${plan.view}`;
    return plan.type || '';
  }

  async function loadMigrations() {
    const refs = [...new Set(state.entities.map((e) => e.Referentiel))].sort();
    const choice = $('#sdmRefView');
    const before = choice.value;
    // TH.8 : « tous » engendre une migration par vue.
    fill(choice, refs.length > 1 ? el('option', { value: '*' }, 'Tous les référentiels') : null, refOptions(refs));
    if (before === '*' || refs.includes(before)) choice.value = before;
    updateButtons();

    skeleton('#sdmTableMigrations tbody', 7);
    const migrations = await request('GET', `${API}/migrations`);
    summary.migrations = migrations;
    showPath();
    const pending = migrations.filter((m) => m.state === 'a_jouer').length;
    pill('#sdmNbPending', pending, 'en attente');
    fillTable('#sdmCardMigrations', '#sdmTableMigrations tbody', migrations.map((m) => el('tr', { className: m.state === 'a_jouer' ? 'sdm-row-pending' : '' },
      el('td', { className: 'sdm-code sdm-col-file', title: m.file }, m.file,
        m.models && m.models.length ? el('div', {
          className: 'sdm-help',
          title: `Modèles Sequelize engendrés (src/database/models) :\n${m.models.map((n) => `db.${n}`).join('\n')}`,
        }, `modèles : ${m.models.slice(0, 2).map((n) => `db.${n}`).join(', ')}${m.models.length > 2 ? ` +${m.models.length - 2}` : ''}`) : null),
      el('td', {}, migrationObject(m.plan)),
      el('td', {}, stateDot(`mg-${m.state}`, MIGRATION_STATES[m.state] || m.state)),
      el('td', {}, m.present ? 'oui' : 'non'),
      el('td', { className: 'text-nowrap' }, date(m.generatedAt), m.generatedBy ? el('div', { className: 'sdm-help' }, m.generatedBy) : null),
      el('td', { className: 'text-nowrap' }, date(m.playedAt)),
      el('td', { className: 'text-right text-nowrap' }, m.state === 'a_jouer' && right('mark')
        ? el('button', {
          type: 'button', className: 'btn btn-outline-secondary btn-sm text-nowrap',
          disabled: !m.present,
          title: m.present ? '' : 'Indisponible : ses objets ne sont pas en base — jouez-la d\'abord',
          onclick: (ev) => busy(ev.currentTarget, async () => {
            await request('PUT', `${API}/migrations/${m.id}/played`);
            notify('success', 'Migration marquée jouée.');
            await Promise.all([loadMigrations(), loadEntities()]);
          }),
        }, icon('validate'), 'Marquer jouée') : null,
      undoButton(m)),
    )), '#sdmMigrationsEmpty', ['migrations', 'Aucune migration engendrée', 'Engendrez la migration d\'un référentiel depuis l\'onglet Entités.']);
  }

  /** DEV : supprimer une migration en attente, annuler (down) une migration jouée. */
  function undoButton(m) {
    if (!state.playMigrations) return null;
    const queued = m.state === 'a_jouer';
    if (!right(queued ? 'generate' : 'mark')) return null;
    const confirmation = queued
      ? {
        title: 'Supprimer la migration ?',
        text: `Le fichier est supprimé${m.models.length ? ', avec ses modèles Sequelize' : ''}.`,
        list: [m.file, ...m.models.map((n) => `db.${n}`)],
        validate: 'Supprimer',
        danger: true,
      }
      : {
        title: 'Annuler la migration ?',
        text: `Son « down » est joué : ses tables et toutes leurs données sont supprimées${
          m.plan.type === 'entities' ? `, ainsi que les imports de ${m.plan.referential} et les décisions de liens de ses entités` : ''
        }. La migration repasse « à jouer ».`,
        list: [m.file],
        validate: 'Annuler la migration',
        danger: true,
      };
    return el('button', {
      type: 'button', className: 'btn btn-outline-danger btn-sm text-nowrap ml-1',
      disabled: Boolean(m.undo),
      title: m.undo ? `Indisponible : ${m.undo}`
        : queued ? 'Supprimer le fichier et ses modèles (DEV)' : 'Rollback : jouer le down (DEV)',
      onclick: async (ev) => {
        const button = ev.currentTarget;
        if (!(await askConfirmation(confirmation))) return;
        busy(button, async () => {
          if (queued) {
            const r = await request('DELETE', `${API}/migrations/${m.id}`);
            notify('success', `Migration supprimée${r.models.length ? ' : redémarrez le serveur' : ''}.`);
          } else {
            const r = await request('POST', `${API}/migrations/${m.id}/undo`);
            notify('success', 'Migration annulée.');
          }
          await loadEntities();
          await loadMigrations();
        });
      },
    }, icon(queued ? 'trash' : 'undo'), queued ? 'Supprimer' : 'Annuler');
  }

  async function diff() {
    const gaps = await request('GET', `${API}/schema/diff`);
    const box = $('#sdmDiff');
    fill(box,
      el('strong', {}, gaps.length ? `${gaps.length} écart(s) entre les déclarations et la base` : 'Aucun écart : la base suit les déclarations.'),
      gaps.length ? el('ul', {}, ...gaps.map((e) => el('li', {},
        el('span', { className: 'sdm-code' }, e.object), ` — ${e.finding} → ${e.action}`))) : null,
    );
    box.classList.remove('d-none');
  }

  // ── Liens ──────────────────────────────────────────────────────────────

  const links = { links: [], entities: [] };

  /** source ─attribut→ cible */
  function linkDrawing(source, attribute, target) {
    return el('span', { className: 'sdm-link' },
      el('span', { className: 'sdm-link-end' }, source),
      el('span', { className: 'sdm-link-arrow' }, attribute ? el('span', {}, attribute) : null, icon('arrow')),
      el('span', { className: 'sdm-link-end' }, target));
  }

  /**
   * L'arbre des liens (E37), sur le modèle de la Hiérarchie Standards.
   *
   * Un lien dit « la source pointe vers la cible » : la cible est donc le
   * parent dans l'arbre, la source son enfant. La parenté déclarée sur l'entité
   * (ParenteCode) compte aussi, quand aucun lien ne la porte déjà : l'arbre se
   * lit avant même la reprise des liens.
   *
   * T2.3.20 : l'arbre ne montre que la hiérarchie interne de chaque
   * référentiel, et chaque entité **une seule fois**, sous sa première cible
   * du même référentiel : plus de sous-arbre recopié sous chaque cible.
   * T2.3.22 : ses autres liens (autre référentiel, seconde cible) sont des
   * flèches tracées à droite de l'arbre ; le lien vers elle-même reste une
   * étiquette « ↻ » sur sa carte.
   */
  const KINDS = { natif: 'native', declare: 'à rapprocher', parente: 'parenté' };
  /** `draft` : le lien en cours de déclaration dans la fiche, ou null. */
  const tree = { selected: null, hideIsolated: false, allArrows: false, draft: null, arrows: [] };

  function linkEdges() {
    const known = new Set(links.entities.map((e) => e.Code));
    const out = links.links.filter((l) => known.has(l.source) && known.has(l.target));
    const carried = new Set(out.map((l) => `${l.source}|${l.target}`));
    links.entities.forEach((e) => {
      if (e.ParenteCode && known.has(e.ParenteCode) && !carried.has(`${e.Code}|${e.ParenteCode}`)) {
        out.push({ source: e.Code, target: e.ParenteCode, nature: 'parente', active: true });
      }
    });
    return out;
  }

  function renderLinkTree() {
    const host = $('#sdmLinkTree');
    const edges = linkEdges();
    const byCode = new Map(links.entities.map((e) => [e.Code, e]));
    // Référentiel, puis l'Ordre des entités (la parenté après reprise ou découpage), puis le code.
    const order = (a, b) => byCode.get(a).Referentiel.localeCompare(byCode.get(b).Referentiel)
      || (byCode.get(a).Ordre ?? 0) - (byCode.get(b).Ordre ?? 0) || a.localeCompare(b);
    const group = (list, key) => list.reduce((m, l) => m.set(l[key], [...(m.get(l[key]) || []), l]), new Map());

    // La place de chaque entité : sa première cible du même référentiel, elle-même exclue.
    const inside = (l) => l.source !== l.target && byCode.get(l.source).Referentiel === byCode.get(l.target).Referentiel;
    const placement = new Map();
    edges.filter(inside).sort((a, b) => order(a.target, b.target))
      .forEach((l) => { if (!placement.has(l.source)) placement.set(l.source, l); });
    const children = group([...placement.values()], 'target');
    children.forEach((list) => list.sort((a, b) => order(a.source, b.source)));
    const outgoing = group(edges, 'source');

    /** Le lien de l'entité vers elle-même, en étiquette « ↻ PARENT_ID ». */
    const refChip = (l) => el('button', {
      type: 'button',
      className: `sdm-lx-ref sdm-lx-kind-${l.nature}${l.active ? '' : ' is-inactive'}`,
      title: `hiérarchie dans la table${l.attribute ? ` par ${l.attribute}` : ''} · ${KINDS[l.nature]}${l.cardinality ? ` · ${l.cardinality}` : ''}${l.active ? '' : ' · inactif'}`,
      onclick: () => selectEntity(l.target),
    },
    `↻ ${l.attribute || 'parent'}`,
    l.decisions && l.decisions.propose ? el('span', { className: 'sdm-lx-ref-attr' }, ` · ${l.decisions.propose} à confirmer`) : null);

    const reached = new Set();
    // T2.3.22 : les autres liens (vers une autre table) sont des flèches,
    // dessinées par-dessus l'arbre une fois en place (drawLinkArrows).
    const arrows = [];
    const card = (code, edge) => {
      reached.add(code);
      const entity = byCode.get(code);
      const others = (outgoing.get(code) || []).filter((l) => l !== edge);
      arrows.push(...others.filter((l) => l.source !== l.target));
      const refs = others.filter((l) => l.source === l.target);
      // Un cycle dans un référentiel : une entité déjà posée ne se redessine pas.
      const kids = [];
      (children.get(code) || []).forEach((k) => { if (!reached.has(k.source)) kids.push(card(k.source, k)); });
      const toConfirm = edge && edge.decisions ? edge.decisions.propose : 0;
      const selected = tree.selected === code;
      const button = el('button', {
          type: 'button', dataset: { code },
          className: `sdm-lx-card${selected ? ' is-selected' : ''}${edge && !edge.active ? ' is-inactive' : ''}`,
          onclick: () => selectEntity(code),
        },
        el('span', { className: 'sdm-lx-code' }, code),
        el('span', { className: 'sdm-lx-tag' }, entity.Referentiel),
        edge && (edge.attribute || edge.targetAttribute)
          ? el('span', { className: 'sdm-lx-meta' }, `${edge.attribute || '—'} → ${edge.targetAttribute || 'code'}`) : null,
        edge && edge.cardinality ? el('span', { className: 'sdm-lx-meta sdm-code' }, edge.cardinality) : null,
        toConfirm ? el('span', { className: 'sdm-lx-flag' }, `${toConfirm} à confirmer`) : null,
        edge && edge.tableState === 'a_engendrer' ? el('span', { className: 'sdm-lx-flag' }, 'table à engendrer') : null,
        edge && !edge.active ? dot('inactif') : null,
        edge ? el('span', { className: `sdm-lx-kind sdm-lx-kind-${edge.nature}` }, KINDS[edge.nature]) : null);
      // Les autres liens à droite, dans le cadre de la carte (pas de bouton dans le bouton).
      return el('li', { role: 'treeitem', 'aria-selected': String(selected), ...(kids.length ? { 'aria-expanded': 'true' } : {}) },
        refs.length
          ? el('div', { className: `sdm-lx-row${selected ? ' is-selected' : ''}` },
            button, el('div', { className: 'sdm-lx-refs' }, ...refs.map(refChip)))
          : button,
        kids.length ? el('ul', { role: 'group' }, ...kids) : null);
    };

    // Les racines : les entités liées sans place dans leur référentiel. Un
    // cycle n'en a pas ; ce qui reste hors d'atteinte devient racine à son tour.
    const involved = [...new Set(edges.flatMap((l) => [l.source, l.target]))].sort(order);
    const roots = [];
    involved.forEach((c) => { if (!placement.has(c) && !reached.has(c)) roots.push(card(c, null)); });
    involved.forEach((c) => { if (!reached.has(c)) roots.push(card(c, null)); });
    const isolated = links.entities.map((e) => e.Code).filter((c) => !involved.includes(c)).sort(order);

    if (!links.entities.length) {
      fill(host, empty('links', 'Aucune entité déclarée', 'Déclarez des entités dans l\'onglet Entités : leurs liens s\'afficheront ici.'));
      return;
    }
    tree.arrows = arrows;
    fill(host,
      roots.length
        ? el('div', { className: 'sdm-lx-canvas' }, el('ul', { className: 'sdm-lx-list', role: 'group' }, ...roots))
        : el('p', { className: 'sdm-lx-none' }, 'Aucun lien : reprenez les liens existants, ou choisissez une entité et déclarez-en un.'),
      isolated.length && !tree.hideIsolated
        ? el('div', { className: 'sdm-lx-isolated' },
          el('h4', {}, `Sans lien (${isolated.length})`),
          el('ul', { className: 'sdm-lx-list sdm-lx-flat', role: 'group' }, ...isolated.map((c) => card(c, null))))
        : null);
    drawLinkArrows();
  }

  /**
   * Les flèches entre tables (T2.3.22), dessinées sur l'arbre déjà en place :
   * elles partent du bord droit de la carte source et entrent dans la carte
   * cible. Deux flèches dont les hauteurs se chevauchent prennent chacune son
   * couloir. Une entité choisie met ses flèches en avant, les autres s'effacent.
   */
  const SVG = 'http://www.w3.org/2000/svg';
  function drawLinkArrows() {
    const canvas = $('#sdmLinkTree .sdm-lx-canvas');
    if (!canvas) return;
    canvas.querySelectorAll('.sdm-lx-arrows, .sdm-lx-arrow-label').forEach((n) => n.remove());
    const arrows = tree.arrows || [];
    // L'onglet caché n'a pas de mesures : on redessinera à son affichage (ResizeObserver).
    if (!arrows.length || !canvas.offsetWidth) { canvas.style.paddingRight = ''; return; }

    const measure = () => {
      const box = canvas.getBoundingClientRect();
      const at = (code) => {
        const b = [...canvas.querySelectorAll('.sdm-lx-card')].find((n) => n.dataset.code === code);
        if (!b) return null;
        const r = (b.closest('.sdm-lx-row') || b).getBoundingClientRect();
        return { x: r.right - box.left, y: r.top - box.top + r.height / 2 };
      };
      return arrows.map((l) => ({ l, s: at(l.source), t: at(l.target) })).filter((a) => a.s && a.t);
    };
    const LANE = 34, FIRST = 26, ROOM = 110;
    let placed = measure();
    const lanes = [];
    placed.map((a) => Object.assign(a, { lo: Math.min(a.s.y, a.t.y), hi: Math.max(a.s.y, a.t.y) }))
      .sort((a, b) => (a.hi - a.lo) - (b.hi - b.lo))
      .forEach((a) => {
        let k = 0;
        while ((lanes[k] || []).some((o) => a.hi >= o.lo - 6 && a.lo <= o.hi + 6)) k++;
        (lanes[k] = lanes[k] || []).push(a);
        a.lane = k;
      });
    // La marge de droite accueille les couloirs ; les cartes rétrécissent, on remesure.
    canvas.style.paddingRight = `${FIRST + LANE * Math.max(0, lanes.length - 1) + ROOM}px`;
    const laneOf = new Map(placed.map((a) => [a.l, a.lane]));
    placed = measure();

    const svg = document.createElementNS(SVG, 'svg');
    svg.setAttribute('class', 'sdm-lx-arrows');
    svg.setAttribute('aria-hidden', 'true');
    svg.setAttribute('width', canvas.scrollWidth);
    svg.setAttribute('height', canvas.scrollHeight);
    const node = (name, attrs) => {
      const n = document.createElementNS(SVG, name);
      Object.entries(attrs).forEach(([k, v]) => n.setAttribute(k, v));
      return n;
    };
    const defs = node('defs', {});
    ['natif', 'declare', 'parente'].forEach((k) => {
      const m = node('marker', {
        id: `sdmArrow-${k}`, viewBox: '0 0 10 10', refX: 9, refY: 5,
        markerWidth: 9, markerHeight: 9, markerUnits: 'userSpaceOnUse', orient: 'auto',
      });
      m.appendChild(node('path', { d: 'M0,0 L10,5 L0,10 z', class: `sdm-lx-head-${k}` }));
      defs.appendChild(m);
    });
    svg.appendChild(defs);

    const labels = [];
    placed.forEach(({ l, s, t }) => {
      const hot = tree.allArrows || !tree.selected || l.source === tree.selected || l.target === tree.selected;
      const x0 = Math.max(s.x, t.x);
      const x1 = x0 + FIRST + LANE * laneOf.get(l);
      const dir = t.y > s.y ? 1 : -1;
      const r = Math.min(10, Math.abs(t.y - s.y) / 2);
      const state = `sdm-lx-arrow is-${l.nature}${l.active ? '' : ' is-inactive'}${hot ? ' is-hot' : ' is-faded'}`;
      const g = node('g', { class: state });
      g.appendChild(node('path', {
        d: `M ${s.x} ${s.y} H ${x1 - r} Q ${x1} ${s.y} ${x1} ${s.y + dir * r} V ${t.y - dir * r} Q ${x1} ${t.y} ${x1 - r} ${t.y} H ${t.x + 1}`,
        'marker-end': `url(#sdmArrow-${l.nature})`,
      }));
      g.appendChild(node('circle', { cx: s.x, cy: s.y, r: 3.5 }));
      svg.appendChild(g);
      const toConfirm = l.decisions ? l.decisions.propose : 0;
      const label = el('button', {
        type: 'button',
        className: `sdm-lx-arrow-label sdm-lx-kind-${l.nature}${hot ? ' is-hot' : ' is-faded'}`,
        title: `${l.source} → ${l.target}${l.attribute ? ` par ${l.attribute}` : ''} · ${KINDS[l.nature]}${l.cardinality ? ` · ${l.cardinality}` : ''}${l.active ? '' : ' · inactif'}`,
        onclick: () => selectEntity(l.source),
      }, l.attribute || `→ ${l.target}`, toConfirm ? ` · ${toConfirm} à confirmer` : '');
      label.style.left = `${x1}px`;
      label.style.top = `${(s.y + t.y) / 2}px`;
      labels.push(label);
    });
    canvas.prepend(svg);
    // Les étiquettes des flèches choisies passent devant.
    labels.sort((a, b) => a.classList.contains('is-hot') - b.classList.contains('is-hot')).forEach((n) => canvas.appendChild(n));
  }

  function selectEntity(code) {
    tree.selected = code;
    renderLinkTree();
    renderLinkSide();
    // L'arbre se redessine : on rend le focus à la carte cliquée.
    const again = [...$('#sdmLinkTree').querySelectorAll('.sdm-lx-card')].find((b) => b.dataset.code === code);
    if (again) again.focus();
  }

  /** Les gestes d'un lien enregistré ; une parenté seule n'en a pas. */
  function linkActions(l) {
    if (!l.id) return [];
    const actions = [];
    if (l.tableState === 'a_engendrer' && right('generate')) {
      actions.push(el('button', {
        type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm sdm-lx-btn-primary',
        onclick: (ev) => busy(ev.currentTarget, async () => {
          const r = await request('POST', `${API}/migrations`, { linkId: l.id });
          notify('success', 'Migration engendrée avec succès.');
          await loadLinks();
        }),
      }, icon('code'), 'Engendrer la table'));
    }
    if (l.nature === 'declare') {
      actions.push(el('button', {
        type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm', onclick: (ev) => busy(ev.currentTarget, () => viewInstances(l)),
      }, icon('eye'), 'Instances'));
    }
    if (right('link')) {
      actions.push(el('button', {
        type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm',
        onclick: (ev) => busy(ev.currentTarget, async () => {
          await request('PUT', `${API}/links/${l.id}`, { active: !l.active });
          await loadLinks();
        }),
      }, l.active ? 'Désactiver' : 'Activer'));
      // T2.3.17 : refusé tant que la table de liaison existe ou est engendrée.
      const kept = { creee: 'sa table de liaison existe : annulez sa migration d\'abord', engendree: 'sa migration est engendrée : supprimez-la d\'abord' }[l.tableState];
      const remove = el('button', {
        type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm',
        onclick: (ev) => busy(ev.currentTarget, async () => {
          const d = l.decisions;
          const decisions = d.confirme + d.reconduit + d.propose + d.caduc + d.rejete;
          const ok = await askConfirmation({
            title: 'Supprimer ce lien ?',
            text: `${decisions ? `Ses ${decisions} décision(s) partent avec lui. ` : ''}`
              + 'Un lien repris de l\'existant reviendra à la prochaine reprise des liens : pour l\'écarter, désactivez-le plutôt.',
            list: [`${l.source}${l.attribute ? `.${l.attribute}` : ''} → ${l.target}${l.targetAttribute ? `.${l.targetAttribute}` : ''}`],
            validate: 'Supprimer',
            danger: true,
          });
          if (!ok) return;
          await request('DELETE', `${API}/links/${l.id}`);
          notify('success', 'Lien supprimé.');
          await loadLinks();
        }),
      }, icon('trash'), 'Supprimer');
      dim(remove, kept);
      actions.push(remove);
    }
    return actions;
  }

  /** Une ligne « libellé : valeur » de carte de lien, comme les besoins de Standards. */
  const linkLine = (name, ...value) => el('div', { className: 'sdm-lx-line' },
    el('span', { className: 'sdm-lx-label' }, name), el('span', { className: 'sdm-lx-value' }, ...value));

  /** Un lien de la fiche. `side` : l'extrémité à nommer (la cible pour « Pointe vers », la source pour « Visée par »). */
  function linkItem(l, side) {
    const d = l.decisions;
    const instances = d && l.nature === 'declare'
      ? `${d.confirme + d.reconduit} établi(s)${d.propose ? `, ${d.propose} à confirmer` : ''}${d.caduc ? `, ${d.caduc} caduc(s)` : ''}${d.rejete ? `, ${d.rejete} rejeté(s)` : ''}`
      : '';
    const actions = linkActions(l);
    // T2.3.22 : l'autre bout mène à son entité ; le type dit escalier, ↻ ou flèche.
    const other = side === 'target' ? l.target : l.source;
    const refOf = (code) => (links.entities.find((e) => e.Code === code) || {}).Referentiel;
    const type = l.source === l.target ? 'Hiérarchie dans la table (↻)'
      : refOf(l.source) === refOf(l.target) ? `Hiérarchie ${refOf(l.source)}` : 'Entre tables';
    const go = el('button', { type: 'button', className: 'sdm-lx-goto', onclick: () => selectEntity(other) },
      other, l.source === l.target ? ' (elle-même)' : '');
    const end = side === 'target'
      ? linkLine('Cible', go, l.targetAttribute ? ` · ${l.targetAttribute}` : '')
      : linkLine('Source', go, l.attribute ? ` · ${l.attribute}` : '');
    return el('li', { className: `sdm-lx-item${l.active ? '' : ' is-inactive'}` },
      el('div', { className: 'sdm-lx-item-head' },
        el('span', { className: `sdm-lx-kind sdm-lx-kind-${l.nature}` }, KINDS[l.nature]),
        l.active ? null : dot('inactif')),
      linkLine('Type', type),
      end,
      side === 'target' && l.nature === 'natif' ? linkLine('Attribut source', l.attribute || '— aucun (pas de table)') : null,
      side === 'source' && l.targetAttribute ? linkLine('Attribut cible', l.targetAttribute) : null,
      l.cardinality ? linkLine('Cardinalité', el('span', { className: 'sdm-code' }, l.cardinality)) : null,
      l.table ? linkLine('Table', el('span', { className: 'sdm-code' }, l.table), ' ', stateDot(l.tableState, TABLE_STATES[l.tableState])) : null,
      instances ? linkLine('Instances', instances) : null,
      l.nature === 'parente' ? el('p', { className: 'sdm-lx-note' }, 'Parenté déclarée sur l\'entité, pas encore reprise en lien : « Reprendre les liens existants » la crée.') : null,
      actions.length ? el('div', { className: 'sdm-lx-item-foot' }, ...actions) : null);
  }

  /**
   * Le lien en cours de déclaration, dans la fiche (le « Nouveau » des besoins
   * Standards). La nature se choisit d'abord, elle commande le reste : seul un
   * lien natif a un attribut source, porté par une colonne du fichier.
   */
  function draftItem() {
    const draft = tree.draft;
    const entityOf = (code) => links.entities.find((e) => e.Code === code);
    const options = (list, selected, first) => [
      el('option', { value: '' }, first),
      ...list.map(([value, text]) => el('option', { value, selected: value === selected }, text)),
    ];
    const attributesOf = (code) => (entityOf(code)?.attributes || [])
      .map((a) => [a.Nom, `${a.Nom}${a.Role !== 'aucun' ? ` (${a.Role})` : ''}`]);
    const set = (key) => (ev) => {
      draft[key] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
      if (key === 'source') {
        draft.attribute = '';
        // La fiche suit la source choisie.
        if (draft.source) { tree.selected = draft.source; renderLinkTree(); }
      }
      if (key === 'target') draft.targetAttribute = '';
      renderLinkSide();
    };
    const select = (key, list, first, extra = {}) => el('select', {
      className: 'form-control form-control-sm sdm-lx-part', onchange: set(key), ...extra,
    }, ...options(list, draft[key], first));
    const entities = links.entities.map((e) => [e.Code, `${e.Code} (${e.Referentiel})`]);
    const complete = draft.source && draft.target;

    return el('li', { className: `sdm-lx-item sdm-lx-draft${complete ? '' : ' is-incomplete'}` },
      el('div', { className: 'sdm-lx-item-head' },
        el('label', { className: 'sdm-lx-check' },
          el('input', { type: 'checkbox', checked: draft.native, onchange: set('native') }), ' Liaison native'),
        el('button', {
          type: 'button', className: 'sdm-lx-del', 'aria-label': 'Abandonner ce lien',
          onclick: () => { tree.draft = null; renderLinkSide(); },
        }, icon('trash'))),
      el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Source'),
        select('source', entities, '—', { 'aria-label': 'Entité source' })),
      draft.native
        ? el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Attribut source'),
          select('attribute', attributesOf(draft.source), '— aucun (pas de table) —', { 'aria-label': 'Attribut source', disabled: !draft.source }))
        : null,
      el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Entité / attr. cible'),
        select('target', entities, '—', { 'aria-label': 'Entité cible' }),
        select('targetAttribute', attributesOf(draft.target), '— code —', { 'aria-label': 'Attribut cible', disabled: !draft.target })),
      el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Cardinalité'),
        el('input', {
          className: 'form-control form-control-sm sdm-lx-part sdm-lx-short', value: draft.cardinality, pattern: '[01n],[01n]',
          'aria-label': 'Cardinalité', oninput: (ev) => { draft.cardinality = ev.target.value; },
        })),
      complete ? null : el('p', { className: 'sdm-lx-warn-line' }, 'Lien à compléter : choisissez la source et la cible.'),
      el('div', { className: 'sdm-lx-item-foot' },
        el('button', {
          type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm sdm-lx-btn-primary', disabled: !complete,
          onclick: (ev) => busy(ev.currentTarget, createLink),
        }, icon('links'), 'Déclarer')));
  }

  /** Ouvre un lien en cours de déclaration, l'entité choisie (sinon la première) comme source. */
  function newLink() {
    const source = tree.selected || (links.entities[0] && links.entities[0].Code) || '';
    tree.draft = { native: true, source, attribute: '', target: '', targetAttribute: '', cardinality: 'n,n' };
    if (source && tree.selected !== source) selectEntity(source);
    else renderLinkSide();
    $('#sdmLinkSide').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }

  async function createLink() {
    const d = tree.draft;
    if (!/^[01n],[01n]$/.test(d.cardinality)) {
      notify('alert', 'Cardinalité : de la forme 1,n');
      return;
    }
    await request('POST', `${API}/links`, {
      source: d.source, attribute: d.native ? d.attribute || null : null, target: d.target,
      targetAttribute: d.targetAttribute || null, nature: d.native ? 'natif' : 'declare', cardinality: d.cardinality,
    });
    notify('success', 'Lien déclaré.');
    tree.selected = d.source;
    tree.draft = null;
    await loadLinks();
  }

  function renderLinkSide() {
    const side = $('#sdmLinkSide');
    const edges = linkEdges();
    const count = (nature) => links.links.filter((l) => l.nature === nature).length;
    const toConfirm = links.links.reduce((n, l) => n + l.decisions.propose, 0);
    const entity = links.entities.find((e) => e.Code === tree.selected);
    if (!entity) tree.selected = null;
    // Tous les liens de l'entité (T2.3.22) : escalier, ↻ et flèches. Le lien
    // vers soi-même se montre une fois, en tête de « Pointe vers ».
    const outgoing = entity ? edges.filter((l) => l.source === entity.Code)
      .sort((a, b) => (b.source === b.target) - (a.source === a.target)) : [];
    const incoming = entity ? edges.filter((l) => l.target === entity.Code && l.source !== entity.Code) : [];
    const draft = tree.draft ? draftItem() : null;

    const head = (title, n, button) => el('div', { className: 'sdm-lx-head' },
      el('h5', { className: 'sdm-lx-subtitle' }, title, ' ', el('span', { className: 'sdm-lx-count' }, String(n))), button);
    fill(side,
      el('section', { className: 'sdm-lx-box' },
        el('h4', { className: 'sdm-lx-title' }, 'Vue d\'ensemble'),
        el('ul', { className: 'sdm-lx-sum' },
          el('li', {}, el('strong', {}, String(links.entities.length)), ' entités'),
          el('li', {}, el('strong', {}, String(count('natif'))), ' liaison(s) native(s)'),
          el('li', {}, el('strong', {}, String(count('declare'))), ' liaison(s) à rapprocher'),
          el('li', {}, el('strong', {}, String(edges.filter((l) => l.nature === 'parente').length)), ' parenté(s) pas encore reprise(s)'),
          toConfirm ? el('li', { className: 'sdm-lx-warn' }, el('strong', {}, String(toConfirm)), ' instance(s) à confirmer') : null)),
      entity
        ? el('section', { className: 'sdm-lx-box' },
          el('div', { className: 'sdm-lx-title-line' },
            el('h4', { className: 'sdm-lx-title' }, entity.Code),
            el('span', { className: 'sdm-lx-sub' }, `${entity.Referentiel} · ${plural(entity.attributes.length, 'attribut', 'attributs')}`)),
          head('Pointe vers', outgoing.length, right('link') && !tree.draft
            ? el('button', { type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm', onclick: newLink }, icon('plus'), 'Nouveau')
            : null),
          draft || outgoing.length
            ? el('ul', { className: 'sdm-lx-items' }, draft, ...outgoing.map((l) => linkItem(l, 'target')))
            : el('p', { className: 'sdm-lx-hint' }, 'Cette entité ne pointe vers aucune autre.'),
          head('Visée par', incoming.length, null),
          incoming.length
            ? el('ul', { className: 'sdm-lx-items' }, ...incoming.map((l) => linkItem(l, 'source')))
            : el('p', { className: 'sdm-lx-hint' }, 'Aucune entité ne pointe vers elle.'))
        : draft
          ? el('section', { className: 'sdm-lx-box' }, head('Nouveau lien', '', null), el('ul', { className: 'sdm-lx-items' }, draft))
          : el('p', { className: 'sdm-lx-empty' }, 'Choisissez une entité dans l\'arbre pour voir et déclarer ses liens.'));
  }

  /**
   * Import des déclarations (T2.3.14) : l'aperçu d'abord (rien n'est écrit),
   * puis, sur confirmation, la création de ce qui manque. Rien n'est écrasé.
   */
  async function importDeclarations(chosen) {
    let file;
    try { file = JSON.parse(await chosen.text()); } catch (_) {
      notify('alert', `${chosen.name} : ce n'est pas un fichier JSON.`);
      return;
    }
    const p = await request('POST', `${API}/declarations`, { file });
    const line = (name, c) => `${name} : ${c.create} à créer, ${c.same} identique(s)${c.differ ? `, ${c.differ} différent(s), non écrasé(s)` : ''}`;
    const differ = [...p.referentials.differList, ...p.entities.differList, ...p.links.differList];
    if (p.problems.length) {
      await askConfirmation({
        title: 'Import impossible',
        text: 'Le fichier vise des entités qui ne sont ni en base ni dans le fichier :',
        list: p.problems,
        validate: 'Fermer',
      });
      return;
    }
    const toCreate = p.referentials.create + p.entities.create + p.links.create;
    if (!toCreate) {
      notify('info', `Rien à créer : tout est déjà là${differ.length ? ` (${differ.length} élément(s) différent(s), non écrasé(s))` : ''}.`);
      return;
    }
    const ok = await askConfirmation({
      title: 'Importer les déclarations ?',
      text: `${line('Référentiels', p.referentials)}. ${line('Entités', p.entities)}. ${line('Liens', p.links)}.`
        + `${differ.length ? ' Les éléments différents gardent leur version de cet environnement :' : ''}`,
      list: differ,
      validate: 'Importer',
    });
    if (!ok) return;
    const r = await request('POST', `${API}/declarations`, { file, apply: true });
    notify('success', `Déclarations importées : ${plural(r.entities.create, 'entité', 'entités')}, ${plural(r.links.create, 'lien', 'liens')}, ${plural(r.referentials.create, 'fiche', 'fiches')}.`);
    await loadEntities();
    await loadMigrations().catch(() => {});
  }

  async function loadLinks() {
    Object.assign(links, await request('GET', `${API}/links`));
    summary.links = links.links;
    showPath();
    renderLinkTree();
    renderLinkSide();
  }

  async function linkBackfill() {
    const r = await request('POST', `${API}/links/backfill`);
    const box = $('#sdmBackfillLinks');
    const c = r.matches || { cards: 0, backfills: 0, already: 0, ignored: [], unpublishedCards: 0 };
    box.replaceChildren(
      el('strong', {}, `Liens repris : ${r.created.length}`),
      el('ul', {},
        ...r.created.map((t) => el('li', { className: 'sdm-code' }, t)),
        ...r.ignored.map((t) => el('li', {}, `ignoré — ${t}`))),
      // T3.2.1 : les correspondances code à code des cartes publiées de Standards.
      el('strong', {}, `Correspondances reprises des cartes Standards : ${c.backfills}`),
      el('ul', {},
        el('li', {}, c.cards
          ? `${plural(c.cards, 'carte publiée lue', 'cartes publiées lues')}${c.already ? `, ${c.already} correspondance(s) déjà présente(s)` : ''}`
          : 'aucune carte publiée dans Standards'),
        c.unpublishedCards ? el('li', {}, `${plural(c.unpublishedCards, 'carte jamais publiée', 'cartes jamais publiées')} : non reprise(s)`) : null,
        ...c.ignored.map((t) => el('li', {}, `ignoré — ${t}`))),
    );
    box.classList.remove('d-none');
    await loadLinks();
  }

  let openLink = null;

  async function viewInstances(l) {
    const instances = await request('GET', `${API}/links/${l.id}/decisions`);
    openLink = l;
    $('#sdmInstancesTitle').replaceChildren(linkDrawing(l.source, l.attribute, l.target));
    const rows = instances.map((d) => el('tr', {},
      el('td', { className: 'sdm-code' }, d.sourceCode || d.sourceId),
      el('td', { className: 'sdm-code' }, d.targetCode || d.targetId),
      el('td', {}, dot(d.versionNum)),
      el('td', {}, stateDot(`dc-${d.state}`, DECISION_STATES[d.state] || d.state)),
      el('td', {}, d.decidedBy || (d.state === 'reconduit' || d.state === 'caduc' ? 'rejeu' : '')),
      el('td', {}, d.reason || ''),
    ));
    $('#sdmTableInstances tbody').replaceChildren(...rows);
    $('#sdmTableInstances').classList.toggle('d-none', !rows.length);
    $('#sdmInstancesEmpty').replaceChildren(...(rows.length ? [] : [empty('links', 'Aucune instance', 'Établissez le lien entre deux codes ci-dessus.')]));
    if (!$('#sdmInstances').classList.contains('ouvert')) openDrawer('#sdmInstances');
  }

  async function establish(ev) {
    const f = ev.target;
    if (!f.reportValidity() || !openLink) return;
    const v = f.elements;
    const r = await request('POST', `${API}/links/${openLink.id}/decisions`, {
      sourceCode: v.sourceCode.value.trim(), targetCode: v.targetCode.value.trim(), versionNum: v.versionNum.value.trim() || undefined,
    });
    notify('success', `Lien établi pour la version ${r.versionNum}.`);
    f.reset();
    await viewInstances(openLink);
    await loadLinks();
  }

  // ── À confirmer (bloc de l'onglet Liens) ───────────────────────────────

  function pill(sel, n, text) {
    const p = $(sel);
    p.textContent = n ? String(n) : '';
    p.setAttribute('aria-label', `${n} ${text}`);
    p.classList.toggle('d-none', !n);
  }

  async function loadConfirm() {
    const queue = await request('GET', `${API}/links/to-confirm`);
    summary.confirm = queue;
    showPath();
    pill('#sdmNbConfirm', queue.length, 'à confirmer');
    const list = $('#sdmListConfirm');
    $('#sdmBlockConfirm').classList.toggle('d-none', !queue.length);
    if (!queue.length) {
      list.replaceChildren();
      return;
    }
    list.replaceChildren(...queue.map((d) => {
      const reason = el('input', {
        className: 'form-control form-control-sm', placeholder: 'Motif (facultatif)',
        'aria-label': `Motif pour ${d.sourceCode} → ${d.targetCode}`, maxLength: 500,
      });
      const settle = (decisionState) => (ev) => busy(ev.currentTarget, async () => {
        await request('PUT', `${API}/links/decisions/${d.id}`, { state: decisionState, reason: reason.value.trim() || undefined });
        notify('success', `${d.sourceCode} → ${d.targetCode} : ${DECISION_STATES[decisionState]}.`);
        await loadConfirm();
      });
      return el('article', { className: 'sdm-card sdm-decision', role: 'listitem' },
        el('div', { className: 'sdm-decision-head' },
          el('strong', {}, d.link),
          dot(`version ${d.versionNum}`, 'sdm-dot-accent'),
          stateDot('dc-propose', DECISION_STATES.propose)),
        linkDrawing(d.sourceCode || d.sourceId, null, d.targetCode || d.targetId),
        d.reason ? el('p', { className: 'sdm-decision-why' }, d.reason) : null,
        right('confirm') ? [
          reason,
          el('div', { className: 'sdm-footer' },
            el('button', { type: 'button', className: 'btn btn-outline-danger btn-sm', onclick: settle('rejete') }, icon('close'), 'Rejeter'),
            el('button', { type: 'button', className: 'btn btn-success btn-sm', onclick: settle('confirme') }, icon('validate'), 'Confirmer')),
        ] : null,
      );
    }));
  }

  // ── Test API (compat Standards) ────────────────────────────────────────

  /** Les champs de chaque fonction : [nom, libellé, genre, indication]. */
  const API_FIELDS = {
    getReferentials: [['languageCode', 'Langue', 'lang'], ['isActive', 'Actives', 'triple']],
    getReferentialData: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang'], ['version', 'Version', 'short'],
      ['itemCodes', 'Codes', 'texte', 'A,B,C'], ['type', 'Type', 'short'], ['maxLevel', 'Niveau max', 'short'],
      ['withParent', 'Avec parents', 'case'], ['normalizeCode', 'Normaliser', 'case'], ['includeQuestions', 'Questions', 'case']],
    searchItems: [['refCode', 'Référentiel', 'ref'], ['query', 'Recherche', 'texte'], ['languageCode', 'Langue', 'lang'],
      ['version', 'Version', 'short'], ['limit', 'Limite', 'short', '50']],
    getChildren: [['refCode', 'Référentiel', 'ref'], ['parentCode', 'Parent', 'texte', 'vide = racines'],
      ['languageCode', 'Langue', 'lang'], ['version', 'Version', 'short']],
    getActiveVersions: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang']],
    getAllVersions: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang']],
    getReferenceTextFromKeys: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang'],
      ['itemCodes', 'Codes', 'texte', 'A,B,C'], ['withCode', 'Avec code', 'case']],
    getXoneStandardLabel: [['section', 'Section', 'texte'], ['code', 'Code', 'texte'], ['languageCode', 'Langue', 'lang']],
  };

  /** Une phrase par fonction, comme la liste de l'onglet Test API de Standards. */
  const API_DESCRIPTIONS = {
    getReferentials: 'Liste tous les référentiels',
    getReferentialData: 'Récupère les données d\'un référentiel',
    searchItems: 'Recherche dans un référentiel',
    getChildren: 'Enfants d\'un élément (racines si vide)',
    getActiveVersions: 'Versions actives',
    getAllVersions: 'Toutes les versions',
    getReferenceTextFromKeys: 'Libellés à partir des codes',
    getXoneStandardLabel: 'Libellé d\'un standard X-One',
  };
  const API_TYPES = { lang: 'select', triple: 'select', case: 'boolean', short: 'string', text: 'string', ref: 'string' };
  // Les paramètres obligatoires, marqués d'un astérisque comme dans Standards.
  const API_REQUIRED = new Set(['refCode', 'query', 'section', 'code']);

  let apiFunction = 'getReferentials';

  /** Un paramètre, au gabarit de Standards : nom, type, astérisque, puis la saisie. */
  function apiField([name, , kind, hint]) {
    const required = kind === 'ref' || API_REQUIRED.has(name);
    let input;
    if (kind === 'lang' || kind === 'triple' || kind === 'case') {
      const choice = {
        lang: [['', 'Toutes les langues'], ['fr', 'fr'], ['en', 'en']],
        triple: [['', 'Toutes les versions'], ['true', 'Versions actives uniquement'], ['false', 'Versions inactives uniquement']],
        case: [['', 'Non (par défaut)'], ['true', 'Oui']],
      }[kind];
      input = el('select', { name: name, id: `sdmApiParam_${name}`, className: 'std-form-control' },
        choice.map(([v, t]) => el('option', { value: v }, t)));
    } else {
      input = el('input', {
        type: 'text', name: name, id: `sdmApiParam_${name}`, className: 'std-form-control',
        placeholder: hint ? `Ex : ${hint}` : (kind === 'ref' ? 'Ex : GOVERNANCE' : ''), spellcheck: false,
        ...(kind === 'ref' ? { list: 'sdmApiRefs' } : {}), ...(required ? { required: true } : {}),
      });
    }
    return el('div', { className: 'std-api-param-group' },
      el('label', { className: 'std-api-param-label', htmlFor: `sdmApiParam_${name}` },
        el('span', { className: 'std-api-param-name' }, name),
        el('span', { className: 'std-api-param-type' }, API_TYPES[kind] || 'string'),
        required ? el('span', { className: 'std-api-param-required' }, '*') : null),
      input);
  }

  function pickApiFunction(func) {
    apiFunction = func;
    $('#sdmApiFunctions').querySelectorAll('.std-api-function-item').forEach((li) => {
      const active = li.dataset.function === func;
      li.classList.toggle('std-active', active);
      li.setAttribute('aria-selected', String(active));
    });
    const fields = (API_FIELDS[func] || []).map(apiField);
    $('#sdmApiFields').replaceChildren(...(fields.length ? fields
      : [el('p', { className: 'sdm-help' }, 'Aucun paramètre requis')]));
  }

  /** La signature affichée, comme Standards : getReferentialData(refCode, …). */
  const apiSignature = (f) => `${f}(${(API_FIELDS[f] || [])
    .filter(([n, , g]) => g === 'ref' || API_REQUIRED.has(n)).map(([n]) => n).join(', ')})`;

  let apiReferentials = null;

  async function loadApi() {
    const list = $('#sdmApiFunctions');
    if (!list.children.length) {
      list.replaceChildren(...Object.keys(API_FIELDS).map((f) => {
        const li = el('li', { className: 'std-api-function-item', role: 'option', tabIndex: 0, onclick: () => pickApiFunction(f) },
          el('div', { className: 'std-api-function-name' }, apiSignature(f)),
          el('div', { className: 'std-api-function-desc' }, API_DESCRIPTIONS[f] || ''));
        li.dataset.function = f;
        li.addEventListener('keydown', (ev) => {
          if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pickApiFunction(f); }
        });
        return li;
      }));
      pickApiFunction(apiFunction);
    }
    if (apiReferentials) return;
    const r = await request('GET', `${API}/api/getReferentials`);
    apiReferentials = r.result.map((x) => x.code);
    $('#sdmApiRefs').replaceChildren(...apiReferentials.map((c) => el('option', { value: c })));
    if (!apiReferentials.length) $('#sdmApiCall').textContent = 'Aucun référentiel servi : jouez d\'abord la vue de compatibilité d\'un référentiel.';
  }

  /** La réponse, au gabarit de Standards : badge SUCCESS / ERROR, durée, JSON. */
  function showApiResponse(response, duration, success) {
    $('#sdmApiStatus').style.display = 'flex';
    const badge = $('#sdmApiBadge');
    badge.className = `std-api-status-badge ${success ? 'std-success' : 'std-error'}`;
    badge.textContent = success ? 'SUCCESS' : 'ERROR';
    $('#sdmApiDuration').textContent = `${Math.round(duration)}ms`;
    const zone = $('#sdmApiResponse');
    zone.className = '';
    zone.replaceChildren(el('pre', { className: 'std-api-response-code' }, JSON.stringify(response, null, 2)));
  }

  async function callApi() {
    const params = new URLSearchParams();
    for (const [name, value] of new FormData($('#sdmFormApi'))) if (String(value).trim()) params.append(name, value);
    const url = `${API}/api/${encodeURIComponent(apiFunction)}${params.size ? `?${params}` : ''}`;
    const start = performance.now();
    try {
      const r = await request('GET', url);
      $('#sdmApiCall').textContent = `${r.func}(${r.arguments.map((a) => JSON.stringify(a)).join(', ')})`;
      // Comme Standards : une réponse { success: false } s'affiche en ERROR.
      showApiResponse(r.result, r.durationMs ?? performance.now() - start, r.result?.success !== false);
    } catch (e) {
      $('#sdmApiCall').textContent = `GET ${url}`;
      showApiResponse({ success: false, error: e.message }, performance.now() - start, false);
    }
  }

  // ── Branchements ───────────────────────────────────────────────────────

  // ── Données ────────────────────────────────────────────────────────────

  const DATA_PAGE_SIZE = 20; // comme Standards (`itemsPerPage`)
  /**
   * Les filtres de Standards (E32) : entité ('' = toutes celles de tête),
   * langue ('' = toutes), version ('current' ou 'all'). parent = { code, entite }
   * quand on filtre sur un objet.
   */
  const DATA_DEFAULTS = { entity: '', lang: 'fr', version: 'current', q: '', parent: null };
  const dataState = { ...DATA_DEFAULTS, offset: 0 };

  const createdEntities = () => state.entities.filter((e) => e.state === 'creee');
  const entityOf = (code) => state.entities.find((e) => e.Code === code);
  const entityName = (code) => { const e = entityOf(code); return (e && label(e.Libelle)) || code; };
  /** Un libellé { fr, en } dans la langue du filtre, sinon celle de la page. */
  const dataLabel = (l) => (l && dataState.lang && l[dataState.lang]) || label(l);

  const query = (p) => new URLSearchParams(Object.entries(p).filter(([, v]) => v !== '' && v != null).map(([k, v]) => [k, String(v)]));
  const exploreUrl = (entity, params) => `${API}/explore/${encodeURIComponent(entity)}?${query(params)}`;

  /** Une valeur du contenu en texte : une liste devient « a, b ». */
  const asText = (v) => (Array.isArray(v) ? v.join(', ')
    : v != null && typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : String(v)));

  /** Le libellé d'une ligne : l'attribut de rôle libellé, sinon le premier texte non vide. */
  function rowLabel(entity, data, lang) {
    const d = (data && (data[lang] || data.fr || data.en)) || {};
    const att = entity && entity.attributes.find((a) => a.Role === 'libelle');
    if (att && d[att.Nom] != null) return asText(d[att.Nom]);
    const code = entity && entity.attributes.find((a) => a.Role === 'code');
    const first = Object.entries(d).find(([k, v]) => (!code || k !== code.Nom) && typeof v === 'string' && v.trim());
    return first ? first[1] : '';
  }

  /** « Tous les référentiels », puis chaque entité créée par son libellé, rangée sous son référentiel. */
  function fillDataChoices() {
    const byRef = new Map();
    for (const e of createdEntities()) {
      if (!byRef.has(e.Referentiel)) byRef.set(e.Referentiel, []);
      byRef.get(e.Referentiel).push(e);
    }
    const sel = $('#sdmDataRef');
    sel.replaceChildren(el('option', { value: '' }, 'Tous les référentiels'),
      ...[...byRef].sort(([a], [b]) => a.localeCompare(b)).map(([ref, list]) => el('optgroup', { label: refName(ref) },
        ...list.map((e) => el('option', { value: e.Code }, dataLabel(e.Libelle) || e.Code)))));
    if (!entityOf(dataState.entity) || entityOf(dataState.entity).state !== 'creee') dataState.entity = '';
    sel.value = dataState.entity;
  }

  function showParentFilter() {
    const zone = $('#sdmDataFilterParent');
    zone.classList.toggle('d-none', !dataState.parent);
    zone.replaceChildren(...(dataState.parent ? [
      el('span', {}, 'Enfants de ', el('strong', { className: 'sdm-code' }, dataState.parent.code), ` (${entityName(dataState.parent.entity)})`),
      el('button', {
        type: 'button', className: 'btn btn-link btn-sm',
        onclick: () => { dataState.parent = null; rerunData(); },
      }, icon('close'), 'Retirer le filtre'),
    ] : []));
  }

  /** Replie les lignes d'enfants dépliées sous une ligne, récursivement. */
  function collapse(tr, child) {
    for (const x of tr.sdmSub[child] || []) {
      if (x.sdmSub) Object.keys(x.sdmSub).forEach((f) => collapse(x, f));
      x.remove();
    }
    delete tr.sdmSub[child];
  }

  /** Le bouton qui déplie, sous la ligne, les enfants d'une entité fille. */
  function childrenButton(tr, entityCode, l, child, n, level) {
    const b = el('button', {
      type: 'button', className: 'sdm-expand', 'aria-expanded': 'false',
      title: `Afficher les enfants (${entityName(child)}) de ${l.code}`,
    }, icon('down'), `${n} ${entityName(child)}`);
    b.addEventListener('click', () => busy(b, async () => {
      if (b.getAttribute('aria-expanded') === 'true') {
        collapse(tr, child);
        b.setAttribute('aria-expanded', 'false');
        tr.classList.toggle('sdm-row-open', Object.keys(tr.sdmSub).length > 0);
        return;
      }
      // Les enfants dans la version et la langue de la ligne.
      const r = await request('GET', exploreUrl(child, { version: l.version_num, parent: l.code, limit: 500 }));
      const rows = r.rows.map((x) => dataRow(child,
        { ...x, version_num: l.version_num, lang: l.lang, current: l.current }, level + 1));
      if (r.total > r.rows.length) {
        rows.push(el('tr', { className: 'sdm-row-child' }, el('td', { colSpan: 9, className: 'sdm-help' },
          `… et ${r.total - r.rows.length} de plus. `,
          el('button', {
            type: 'button', className: 'btn btn-link btn-sm p-0',
            onclick: () => {
              Object.assign(dataState, { entity: child, parent: { code: l.code, entity: entityCode }, lang: l.lang });
              if (!l.current) dataState.version = 'all';
              $('#sdmDataLang').value = dataState.lang;
              $('#sdmDataVersion').value = dataState.version;
              rerunData();
            },
          }, 'Tout voir'))));
      }
      tr.sdmSub[child] = rows;
      tr.after(...rows);
      b.setAttribute('aria-expanded', 'true');
      tr.classList.add('sdm-row-open');
    }));
    return b;
  }

  /** Une pastille std-badge, comme les cellules de l'onglet Données de Standards. */
  const badge = (kind, text) => el('span', { className: `std-badge std-badge-${kind}` }, text);

  /** Une ligne au gabarit de Standards ; `niveau` > 0 pour les enfants dépliés dessous. */
  function dataRow(entityCode, l, level = 0) {
    const tr = el('tr', { className: level ? 'sdm-row-child' : '' });
    tr.sdmSub = {};
    const entity = entityOf(entityCode);
    const children = Object.entries(l.children || {}).filter(([, n]) => n > 0);
    const active = l.current;
    const text = rowLabel(entity, l.data, l.lang);
    // Enfant : retrait par niveau, coude « ↳ » et nom de l'entité sur sa propre ligne,
    // pour que la cellule passe à la ligne au lieu d'élargir le tableau. Sous
    // « Tous les référentiels », les lignes de tête disent aussi leur entité.
    const withEntity = level || !dataState.entity;
    const cellCode = el('td', { className: withEntity ? 'sdm-code-child' : 'text-nowrap' },
      withEntity ? el('span', { className: 'sdm-child-entity' }, entityName(entityCode)) : null,
      el('span', { className: 'sdm-child-code' }, level ? el('span', { className: 'sdm-elbow', 'aria-hidden': 'true' }, '↳') : null, badge('code', l.code)));
    if (level) cellCode.style.paddingLeft = `${15 + (level - 1) * 20}px`;
    tr.append(
      el('td', {}, badge('code', entity ? entity.Referentiel : '')),
      cellCode,
      el('td', {}, l.parent_code ? badge('code', l.parent_code) : null),
      el('td', { className: 'sdm-data-label', title: text }, text),
      el('td', {}, badge('language', l.lang === 'en' ? 'Anglais' : 'Français')),
      el('td', {}, badge('version', l.version_num)),
      el('td', {}, badge(active ? 'status-active' : 'status-inactive', active ? 'Actif' : 'Inactif')),
      el('td', {}, children.length
        ? children.map(([child, n]) => childrenButton(tr, entityCode, l, child, n, level))
        : el('span', { className: 'sdm-help' }, '—')),
      el('td', {}, el('button', {
        type: 'button', className: 'std-button std-btn-sm std-btn-secondary', title: 'Détail', 'aria-label': `Détail de ${l.code}`,
        onclick: () => openDataRow(entityCode, l),
      }, icon('eye'))),
    );
    return tr;
  }

  /** Le détail sous le tableau, champ par champ, comme le panneau de Standards. */
  function openDataRow(entityCode, l) {
    const entity = entityOf(entityCode);
    const d = (l.data && l.data[l.lang]) || {};
    const declared = entity ? entity.attributes.map((a) => a.Nom).filter((n) => n in d) : [];
    const names = [...new Set([...declared, ...Object.keys(d)])];
    const field = (name, value) => el('div', { className: 'std-detail-field' },
      el('div', { className: 'std-detail-field-label' }, name),
      el('div', { className: 'std-detail-field-value' }, value == null || value === '' ? '—' : asText(value)));
    $('#sdmDataDetailTitle').textContent = [l.code, rowLabel(entity, l.data, l.lang)].filter(Boolean).join(' - ');
    fill($('#sdmDataDetailContent'),
      names.map((n) => field(n, d[n])),
      field('Entité', entityName(entityCode)),
      field('Version', l.version_num),
      l.ordre != null ? field('Ordre', l.ordre) : null,
      field('Empreinte', l.empreinte));
    const panel = $('#sdmDataDetail');
    panel.style.display = 'block';
    panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }

  /** Le message d'une table vide, centré dans le tableau comme chez Standards. */
  function emptyDataRow(iconName, text) {
    return el('tr', {}, el('td', { colSpan: 9, className: 'sdm-data-empty' }, icon(iconName), el('div', {}, text)));
  }

  /** « Affichage a-b sur n » et les boutons « 1 … 4 5 6 … 12 », comme Standards. */
  function dataPagination(total) {
    const page = Math.floor(dataState.offset / DATA_PAGE_SIZE) + 1;
    const pages = Math.ceil(total / DATA_PAGE_SIZE);
    const start = total ? dataState.offset + 1 : 0;
    const end = Math.min(dataState.offset + DATA_PAGE_SIZE, total);
    $('#sdmDataCount').textContent = `Affichage ${start}-${end} sur ${total}`;
    const go = (p) => { dataState.offset = (p - 1) * DATA_PAGE_SIZE; loadData().catch((err) => notify('alert', err.message)); };
    const button = (text, p, arrow) => el('button', {
      type: 'button', className: `std-pagination-btn${p === page && !arrow ? ' std-active' : ''}`,
      disabled: p < 1 || p > pages, onclick: () => go(p),
      ...(arrow ? { 'aria-label': arrow } : {}),
    }, text);
    const buttons = [];
    if (pages > 1) {
      buttons.push(button('«', page - 1, 'Page précédente'));
      for (let i = 1; i <= pages; i += 1) {
        if (i === 1 || i === pages || (i >= page - 2 && i <= page + 2)) buttons.push(button(String(i), i));
        else if (i === page - 3 || i === page + 3) buttons.push(el('span', { className: 'sdm-pages-ellipsis' }, '...'));
      }
      buttons.push(button('»', page + 1, 'Page suivante'));
    }
    fill($('#sdmDataPages'), buttons);
  }

  /** Le spinner de Standards à la place du tableau pendant la lecture. */
  function dataLoading(active) {
    $('#sdmDataLoading').style.display = active ? 'flex' : 'none';
    $('#sdmCardData').style.display = active ? 'none' : '';
    $('#sdmDataPagination').style.display = active ? 'none' : '';
  }

  async function loadData() {
    fillDataChoices();
    showParentFilter();
    $('#sdmDataDetail').style.display = 'none';
    const body = $('#sdmTableData tbody');
    if (!createdEntities().length) {
      fill(body, emptyDataRow('records', "Aucune table de données : engendrez et jouez la migration d'un référentiel, puis intégrez un import."));
      dataPagination(0);
      return;
    }
    dataLoading(true);
    let r;
    try {
      r = await request('GET', `${API}/data?${query({
        entity: dataState.entity, lang: dataState.lang, version: dataState.version, q: dataState.q,
        parent: dataState.parent ? dataState.parent.code : '', limit: DATA_PAGE_SIZE, offset: dataState.offset,
      })}`);
    } finally {
      dataLoading(false);
    }
    fill(body, r.rows.length ? r.rows.map((l) => dataRow(l.entity, l))
      : emptyDataRow(dataState.q ? 'search' : 'records', dataState.q ? `Rien ne correspond à « ${dataState.q} ».`
        : "Aucune donnée importée : intégrez un import depuis l'onglet Imports."));
    dataPagination(r.total);
  }

  function rerunData() {
    dataState.offset = 0;
    loadData().catch((e) => notify('alert', e.message));
  }

  async function loadAll() {
    await loadEntities();
    await Promise.allSettled([loadMigrations(), loadImports(), loadLinks(), loadConfirm()]);
  }

  document.addEventListener('DOMContentLoaded', () => {
    setIcons();
    applyRights();
    showPath();
    wireDropzones();

    document.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => openTab(b.dataset.tab)));
    $('.sdm-tabs').addEventListener('keydown', tabsKeyboard);
    $('#sdmFilterRef').addEventListener('change', () => { showEntities(); updateButtons(); });
    $('#sdmRefView').addEventListener('change', updateButtons);
    $('#sdmFormImport').elements.referential.addEventListener('change', () => {
      // Les fichiers et la version déposés pour le référentiel précédent ne le suivent pas.
      clearImportInput();
      showImportSheet();
      updateButtons();
      loadHelp().catch((e) => notify('alert', e.message));
    });
    $('#sdmSearch').addEventListener('input', showEntities);
    form().elements.Referentiel.addEventListener('change', (ev) => {
      parentOptions(ev.target.value, null);
      form().elements.Section.value = refSection(ev.target.value) || '';
    });

    document.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', closeDrawer));
    $('#sdmOverlay').addEventListener('click', closeDrawer);
    document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeDrawer(); });

    $('#sdmBtnRefresh').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
      await loadAll();
      notify('success', 'Données actualisées.');
    }));
    $('#sdmBtnNew').addEventListener('click', () => edit(null));
    $('#sdmBtnAddAttribute').addEventListener('click', () => {
      const tr = attributeRow();
      $('#sdmTableAttributes tbody').append(tr);
      tr.querySelector('[name="Nom"]').focus();
    });
    $('#sdmBtnSave').addEventListener('click', (ev) => busy(ev.currentTarget, save));

    $('#sdmBtnBackfill').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
      const filter = $('#sdmFilterRef').value;
      const r = await request('POST', `${API}/backfill`, { referentials: filter ? [filter] : [] });
      showFindings(r);
      await loadEntities();
    }));

    $('#sdmBtnPlay').addEventListener('click', async (ev) => {
      const button = ev.currentTarget;
      const migrations = summary.migrations || await request('GET', `${API}/migrations`).catch(() => []);
      const queued = migrations.filter((m) => m.state === 'a_jouer').map((m) => m.file);
      const ok = await askConfirmation({
        title: 'Jouer les migrations ?',
        text: queued.length
          ? `${queued.length > 1 ? `Les ${queued.length} migrations en attente sont jouées` : 'La migration en attente est jouée'}, dans l'ordre : elles créent leurs tables et vues dans la base.`
          : 'Les migrations en attente sont jouées : elles créent leurs tables et vues dans la base.',
        list: queued,
        validate: 'Jouer',
      });
      if (!ok) return;
      busy(button, async () => {
        const r = await request('POST', `${API}/migrations/play`);
        // Un seul toast, quel que soit le nombre de migrations (T2.3.13).
        notify('success', r.played.length ? `${plural(r.played.length, 'migration jouée', 'migrations jouées')} avec succès.` : 'Aucune migration en attente.');
        await loadEntities();
        await loadMigrations().catch(() => {});
      });
    });

    $('#sdmBtnReset').addEventListener('click', async (ev) => {
      const button = ev.currentTarget;
      const ok = await askConfirmation({
        title: 'Tout remettre à zéro ?',
        text: 'Sont supprimés : le schéma xone_ref et toutes ses données, les migrations et modèles engendrés, '
          + 'les entités, attributs, liens, décisions et imports. Irréversible.',
        validate: 'Tout remettre à zéro',
        danger: true,
      });
      if (!ok) return;
      busy(button, async () => {
        const r = await request('POST', `${API}/reset`);
        notify('success', `Remis à zéro : ${r.removedFiles.length} migration(s) et ${r.removedModels.length} modèle(s) retirés`
          + `${r.removedModels.length ? ' — redémarrez le serveur' : ''}.`);
        await loadAll();
      });
    });

    $('#sdmFormImport').addEventListener('submit', (ev) => {
      ev.preventDefault();
      // Contrôle avant envoi (TH.12) : FR et EN, ou l'extraction source ; aucun fichier vide.
      const file = (n) => ev.target.elements[n]?.files?.[0];
      const source = file('file');
      const byLang = file('fileFr') || file('fileEn');
      if (source && byLang) return notify('warning', 'Déposez soit les fichiers FR et EN, soit l\'extraction source, pas les deux.');
      if (!source && !(file('fileFr') && file('fileEn'))) return notify('warning', 'Déposez les deux fichiers, FR et EN.');
      if (source && !$('[name=fileL4]').closest('.sdm-dropzone').classList.contains('d-none') && !file('fileL4')) {
        return notify('warning', 'Déposez aussi l\'extraction du niveau 4.');
      }
      const files = ['fileFr', 'fileEn', 'file', 'fileL4', 'fileStdFr', 'fileStdEn'].map(file).filter(Boolean);
      const empty = files.find((f) => !f.size);
      if (empty) return notify('warning', `Le fichier ${empty.name} est vide.`);
      const versionField = ev.target.elements.versionNum;
      if (!versionField.value.trim()) {
        versionField.focus();
        return notify('warning', 'Saisissez le numéro de version avant d\'analyser.');
      }
      return busy($('#sdmBtnAnalyze'), async () => {
        const r = await runTask($('#sdmBtnAnalyze'), 'POST', `${API}/imports/analyze`, new FormData(ev.target));
        const f = ev.target.elements;
        showReport({ id: r.id, state: 'analyse', referential: f.referential.value, versionNum: r.versionNum, report: r.report, stats: r.stats });
        await loadImports();
      });
    });

    $('#sdmBtnBackfillData').addEventListener('click', (ev) => busy(ev.currentTarget, dataBackfill));

    // T2.3.14 : les déclarations d'un environnement à l'autre.
    $('#sdmBtnExport').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
      const file = await request('GET', `${API}/declarations`);
      const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
      const a = el('a', { href: URL.createObjectURL(blob), download: `sdm-declarations-${file.exportedAt.slice(0, 10)}.json` });
      document.body.append(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(a.href);
      notify('success', `Déclarations exportées : ${plural(file.entities.length, 'entité', 'entités')}, ${plural(file.links.length, 'lien', 'liens')}.`);
    }));
    $('#sdmBtnImportDecl').addEventListener('click', () => $('#sdmFileDecl').click());
    $('#sdmFileDecl').addEventListener('change', (ev) => {
      const input = ev.target;
      const chosen = input.files[0];
      input.value = '';
      if (chosen) busy($('#sdmBtnImportDecl'), () => importDeclarations(chosen));
    });

    // T2.3.13 : une migration par référentiel, un seul toast pour tout le lot.
    $('#sdmBtnGenerateAll').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
      const r = await request('POST', `${API}/migrations`, { referential: '*' });
      notify(r.generated.length ? 'success' : 'info', r.generated.length
        ? `${plural(r.generated.length, 'migration engendrée', 'migrations engendrées')} avec succès${r.ignored.length ? ` (${r.ignored.length} référentiel(s) ignoré(s))` : ''}.`
        : 'Aucune migration engendrée.');
      const box = $('#sdmFindings');
      if (r.ignored.length) {
        fill(box,
          el('strong', {}, 'Référentiels ignorés'),
          el('ul', {}, r.ignored.map((x) => el('li', {}, `${refName(x.referential)} : ${x.reason}`))));
        box.classList.remove('d-none');
      } else {
        box.classList.add('d-none');
      }
      await loadEntities();
      await loadMigrations().catch(() => {});
    }));

    $('#sdmBtnView').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
      const view = $('#sdmRefView').value;
      const r = await request('POST', `${API}/migrations`, { view });
      if (view === '*') {
        notify(r.generated.length ? 'success' : 'info', `${plural(r.generated.length, 'vue engendrée', 'vues engendrées')}`
          + `${r.ignored.length ? `, ${r.ignored.length} ignorée(s)` : ''}.`);
        const box = $('#sdmDiff');
        fill(box,
          el('strong', {}, 'Engendrer toutes les vues'),
          el('ul', {},
            r.generated.map((x) => el('li', {}, el('span', { className: 'sdm-code' }, x.file), ` — ${x.view}`)),
            r.ignored.map((x) => el('li', {}, `${refName(x.referential)} ignoré : ${x.reason}`))));
        box.classList.remove('d-none');
      } else {
        notify('success', 'Vue engendrée avec succès.');
      }
      // L'état des entités et des gestes change : les deux sont rechargés (TH.5).
      await loadEntities();
      await loadMigrations();
    }));
    $('#sdmBtnDiff').addEventListener('click', (ev) => busy(ev.currentTarget, diff));

    $('#sdmBtnBackfillLinks').addEventListener('click', (ev) => busy(ev.currentTarget, linkBackfill));
    $('#sdmBtnNewLink').addEventListener('click', newLink);
    $('#sdmHideIsolated').addEventListener('change', (ev) => { tree.hideIsolated = ev.target.checked; renderLinkTree(); });
    $('#sdmAllArrows').addEventListener('change', (ev) => { tree.allArrows = ev.target.checked; drawLinkArrows(); });
    // Les flèches suivent la largeur de l'arbre, et se dessinent quand l'onglet s'affiche.
    if (window.ResizeObserver) {
      let width = 0;
      new ResizeObserver(([entry]) => {
        if (entry.contentRect.width === width) return;
        width = entry.contentRect.width;
        requestAnimationFrame(drawLinkArrows);
      }).observe($('#sdmLinkTree'));
    }
    $('#sdmFormInstance').addEventListener('submit', (ev) => { ev.preventDefault(); busy(ev.submitter || ev.target, () => establish(ev)); });

    $('#sdmFormApi').addEventListener('submit', (ev) => { ev.preventDefault(); busy($('#sdmBtnApi'), callApi); });


    $('#sdmDataRef').addEventListener('change', (ev) => { dataState.entity = ev.target.value; dataState.parent = null; rerunData(); });
    $('#sdmDataVersion').addEventListener('change', (ev) => { dataState.version = ev.target.value; rerunData(); });
    $('#sdmDataLang').addEventListener('change', (ev) => { dataState.lang = ev.target.value; rerunData(); });
    let searchDelay = null;
    $('#sdmDataSearch').addEventListener('input', (ev) => {
      clearTimeout(searchDelay);
      searchDelay = setTimeout(() => { dataState.q = ev.target.value.trim(); rerunData(); }, 300);
    });
    $('#sdmDataClear').addEventListener('click', () => {
      // Comme « Effacer » de Standards, mais vers nos valeurs de départ (E32).
      clearTimeout(searchDelay);
      Object.assign(dataState, DATA_DEFAULTS);
      $('#sdmDataSearch').value = '';
      $('#sdmDataLang').value = dataState.lang;
      $('#sdmDataVersion').value = dataState.version;
      rerunData();
    });
    $('#sdmDataDetailClose').addEventListener('click', () => { $('#sdmDataDetail').style.display = 'none'; });

    loadAll().catch((e) => notify('alert', e.message));
  });
}());

Enter fullscreen mode Exit fullscreen mode

Top comments (0)