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; }
/**
* 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,
};
<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>
/**
* 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));
});
}());
Top comments (0)