From 5338f682af852ac6fa5fff0be6ffd618a8ccfc27 Mon Sep 17 00:00:00 2001 From: Alexis Burnaz <48258099+alxsbrz@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:24:37 +0200 Subject: [PATCH] RC UI: uniformiser labels flottants et fiabiliser blocages projet/tarif --- ecole/public/css/global.css | 31 ++ ecole/public/js/projet-form-rc.js | 41 +- ecole/public/js/rc-validation-utils.js | 552 +++++++++++++++++++++++++ ecole/public/js/tarif-form-rc.js | 50 +++ ecole/views/layout.ejs | 2 + ecole/views/projetformrc.ejs | 2 +- ecole/views/tarifformrc.ejs | 34 +- 7 files changed, 693 insertions(+), 19 deletions(-) create mode 100644 ecole/public/js/rc-validation-utils.js diff --git a/ecole/public/css/global.css b/ecole/public/css/global.css index 3a502e3a..d4b72e7a 100644 --- a/ecole/public/css/global.css +++ b/ecole/public/css/global.css @@ -458,6 +458,37 @@ a.grille-garanties:hover{ text-align: left; } +.rc-has-floating-label { + position: relative; + margin-top: 1.8rem; +} + +.rc-has-floating-label .rc-field-label.rc-floating-label { + position: absolute; + top: 0.95rem; + left: 0.75rem; + margin: 0; + font-size: 1rem; + font-weight: 500; + color: #7f8c8d; + pointer-events: none; + transition: top .18s ease, font-size .18s ease, color .18s ease; + background: #fff; + padding: 0 4px; + z-index: 2; +} + +.rc-has-floating-label .rc-field-label.rc-floating-label.active { + top: -0.55rem; + font-size: .78rem; + font-weight: 700; + color: #1a237e; +} + +.rc-tarifettes-hidden { + display: none !important; +} + .rc-three-col-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/ecole/public/js/projet-form-rc.js b/ecole/public/js/projet-form-rc.js index 65488c25..43dcc59e 100644 --- a/ecole/public/js/projet-form-rc.js +++ b/ecole/public/js/projet-form-rc.js @@ -13,6 +13,12 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio let hasSavedGrilleData = false; // évite d'écraser une grille déjà enregistrée let rcProjetGuard = null; + function syncRCFloatingLabels() { + if (window.RCValidationUtils && typeof window.RCValidationUtils.syncFloatingLabels === 'function') { + window.RCValidationUtils.syncFloatingLabels(document); + } + } + // Initialisation des tag pour select var tagAnimauxVivants = false; var tagMultimodal = false; @@ -90,11 +96,13 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio } updatePrimeReferenceRow(); setupRCSafeValidation(); + syncRCFloatingLabels(); updateSubmitButtonState('projetForm'); setTimeout(() => { updatePrimeReferenceRow(); if (rcProjetGuard) rcProjetGuard.refresh(); + syncRCFloatingLabels(); }, 350); } @@ -251,7 +259,13 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio rcProjetGuard = window.RCValidationUtils.createGuard({ summaryId: 'rcProjetBlockingSummary', summaryTitle: 'Impossible d\'enregistrer ou de continuer car :', - blockTargets: ['#projetFormBtn', '#generateDeclinaison', '#generateProject'] + blockTargets: ['#projetFormBtn', '#generateDeclinaison', '#generateProject'], + onChange: function (messages) { + const actionsRow = document.getElementById('projetActionsRow'); + if (actionsRow) { + actionsRow.classList.toggle('rc-tarifettes-hidden', messages.length > 0); + } + } }); rcProjetGuard.registerField('#CA', { @@ -379,6 +393,21 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio required: false }); + rcProjetGuard.registerExternal('marchandises-valides', function () { + const marchandiseSelector = document.getElementById('marchandise-selector'); + if (!marchandiseSelector) { + return { valid: true }; + } + const selectedCount = Array.from(marchandiseSelector.selectedOptions || []).length; + if (selectedCount > 0) { + return { valid: true }; + } + return { + valid: false, + message: 'Marchandises invalides : sélectionnez au moins une marchandise.' + }; + }); + rcProjetGuard.registerExternal('prime-reference', function () { const state = getPrimeReferenceState(); if (state.valid) { @@ -2105,6 +2134,7 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio // Réactiver la détection juste après les dispatchs pour conserver le contrôle. setTimeout(() => { isRestoringValue = false; + syncRCFloatingLabels(); }, 0); } @@ -2118,18 +2148,26 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio document.getElementById('activity-selector').addEventListener('change', function () { handleActivitySelection(); + if (rcProjetGuard) rcProjetGuard.refresh(); + syncRCFloatingLabels(); }); document.getElementById('marchandise-selector').addEventListener('change', function () { handleMarchandiseSelection(); + if (rcProjetGuard) rcProjetGuard.refresh(); + syncRCFloatingLabels(); }); document.getElementById('garantieRCC-selector').addEventListener('change', function () { handleGarantieRCCSelection(); + if (rcProjetGuard) rcProjetGuard.refresh(); + syncRCFloatingLabels(); }); document.getElementById('garantieRCE-selector').addEventListener('change', function () { handleGarantieRCESelection(); + if (rcProjetGuard) rcProjetGuard.refresh(); + syncRCFloatingLabels(); }); document.getElementById('choixRCE').addEventListener('change', function () { @@ -3192,6 +3230,7 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio updatePrimeReferenceRow(); if (rcProjetGuard) rcProjetGuard.refresh(); + syncRCFloatingLabels(); } function populateGrAdvalo(jsonData, tableID) { diff --git a/ecole/public/js/rc-validation-utils.js b/ecole/public/js/rc-validation-utils.js new file mode 100644 index 00000000..380d05ff --- /dev/null +++ b/ecole/public/js/rc-validation-utils.js @@ -0,0 +1,552 @@ +(function () { + function normalizeNumericInput(raw) { + if (raw == null) return ''; + return String(raw).trim().replace(/\s+/g, ''); + } + + function parseLooseNumber(raw) { + const normalized = normalizeNumericInput(raw).replace(',', '.'); + if (!normalized) return NaN; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : NaN; + } + + function formatFrenchAmount(value, digits) { + const number = Number(value); + if (!Number.isFinite(number)) return '0.00'; + return number.toLocaleString('fr-FR', { + minimumFractionDigits: digits, + maximumFractionDigits: digits + }); + } + + function syncFloatingLabels(root) { + const scope = root && root.querySelectorAll ? root : document; + const labels = scope.querySelectorAll('.rc-field-label[for]'); + labels.forEach(function (label) { + const fieldId = label.getAttribute('for'); + if (!fieldId) return; + const field = document.getElementById(fieldId); + if (!field) return; + + const inputField = label.closest('.input-field'); + const container = inputField || label.parentElement; + if (container) { + container.classList.add('rc-has-floating-label'); + } + + label.classList.add('rc-floating-label'); + if (label.dataset.rcFloatBound === 'true') { + // Déjà lié: on force juste un resync visuel. + const hasValue = String(field.value || '').trim() !== ''; + label.classList.toggle('active', hasValue || document.activeElement === field); + return; + } + + const hasPrefix = Boolean(inputField && inputField.querySelector('.prefix')); + label.style.left = hasPrefix ? '3rem' : '0.75rem'; + + const syncState = function () { + const hasValue = String(field.value || '').trim() !== ''; + label.classList.toggle('active', hasValue || document.activeElement === field); + }; + + field.addEventListener('focus', syncState); + field.addEventListener('blur', syncState); + field.addEventListener('input', syncState); + field.addEventListener('change', syncState); + label.dataset.rcFloatBound = 'true'; + syncState(); + }); + + if (window.M && typeof window.M.updateTextFields === 'function') { + window.M.updateTextFields(); + } + } + + function ensureErrorSlot(field, customErrorId) { + if (!field) return null; + + if (customErrorId) { + const found = document.getElementById(customErrorId); + if (found) return found; + } + + if (field.id) { + const byConvention = document.getElementById(field.id + '-error'); + if (byConvention) return byConvention; + } + + if (field.dataset && field.dataset.errorTarget) { + const byData = document.getElementById(field.dataset.errorTarget); + if (byData) return byData; + } + + let sibling = field.parentElement ? field.parentElement.querySelector('.rc-inline-error') : null; + if (sibling) return sibling; + + sibling = document.createElement('span'); + sibling.className = 'helper-text red-text rc-inline-error'; + sibling.style.display = 'none'; + field.insertAdjacentElement('afterend', sibling); + return sibling; + } + + function pickFieldLabel(field, fallbackLabel) { + if (fallbackLabel) return fallbackLabel; + if (!field) return 'Champ'; + + if (field.dataset && field.dataset.rcLabel) return field.dataset.rcLabel; + + const directLabel = field.id ? document.querySelector('label[for="' + field.id + '"]') : null; + if (directLabel && directLabel.textContent.trim()) return directLabel.textContent.trim(); + + const cardLabel = field.closest('.row, .input-field, td, .card-content')?.querySelector('.rc-field-label'); + if (cardLabel && cardLabel.textContent.trim()) return cardLabel.textContent.trim(); + + const th = field.closest('td')?.parentElement?.querySelector('th'); + if (th && th.textContent.trim()) return th.textContent.trim(); + + return field.name || field.id || 'Champ'; + } + + function setFieldError(field, errorSlot, message) { + if (!errorSlot) return; + errorSlot.textContent = message || ''; + errorSlot.style.display = message ? 'block' : 'none'; + + if (field) { + if (message) { + field.classList.add('invalid'); + } else { + field.classList.remove('invalid'); + } + } + } + + function hasForbiddenNumericChars(value) { + return /[A-Za-z€$£¥]/.test(value); + } + + function validateNumeric(value, options) { + const raw = normalizeNumericInput(value); + const decimals = Number.isInteger(options.decimals) ? options.decimals : 2; + const label = options.label || 'Champ'; + const required = Boolean(options.required); + + if (!raw) { + if (required) { + return { valid: false, message: label + ' est obligatoire.' }; + } + return { valid: true }; + } + + if (hasForbiddenNumericChars(raw)) { + return { valid: false, message: label + ' doit contenir uniquement des chiffres, avec virgule ou point décimal.' }; + } + + if (/[^0-9.,-]/.test(raw)) { + return { valid: false, message: label + ' contient des caractères interdits.' }; + } + + if ((raw.match(/,/g) || []).length > 1 || (raw.match(/\./g) || []).length > 1) { + return { valid: false, message: label + ' a un format invalide.' }; + } + + if (raw.includes('-') && raw.indexOf('-') !== 0) { + return { valid: false, message: label + ' a un format invalide.' }; + } + + const decimalRegex = new RegExp('^-?\\d+(?:[.,]\\d{1,' + decimals + '})?$'); + if (!decimalRegex.test(raw)) { + return { valid: false, message: label + ' doit être un nombre valide (max ' + decimals + ' décimales).' }; + } + + const number = parseLooseNumber(raw); + if (!Number.isFinite(number)) { + return { valid: false, message: label + ' doit être un nombre valide.' }; + } + + if (typeof options.min === 'number' && number < options.min) { + return { valid: false, message: label + ' doit être supérieur ou égal à ' + options.min + '.' }; + } + + if (typeof options.max === 'number' && number > options.max) { + return { valid: false, message: label + ' doit être inférieur ou égal à ' + options.max + '.' }; + } + + if (options.positive && number <= 0) { + return { valid: false, message: label + ' doit être strictement supérieur à 0.' }; + } + + return { valid: true, normalized: raw.replace(',', '.') }; + } + + function validateInteger(value, options) { + const raw = normalizeNumericInput(value); + const label = options.label || 'Champ'; + const required = Boolean(options.required); + + if (!raw) { + if (required) { + return { valid: false, message: label + ' est obligatoire.' }; + } + return { valid: true }; + } + + if (!/^\d+$/.test(raw)) { + return { valid: false, message: label + ' doit contenir uniquement des chiffres entiers.' }; + } + + const number = Number(raw); + if (typeof options.min === 'number' && number < options.min) { + return { valid: false, message: label + ' doit être supérieur ou égal à ' + options.min + '.' }; + } + if (typeof options.max === 'number' && number > options.max) { + return { valid: false, message: label + ' doit être inférieur ou égal à ' + options.max + '.' }; + } + + return { valid: true }; + } + + function validateImmat(value, options) { + const raw = String(value || '').trim().toUpperCase(); + const label = options.label || 'Immatriculation'; + + if (!raw) { + if (options.required) { + return { valid: false, message: label + ' est obligatoire.' }; + } + return { valid: true, normalized: '' }; + } + + if (!/^[A-Z0-9-]+$/.test(raw)) { + return { valid: false, message: label + ' doit contenir uniquement des lettres majuscules, chiffres et tirets.' }; + } + + return { valid: true, normalized: raw }; + } + + function validateTextSafe(value, options) { + const raw = String(value || '').trim(); + const label = options.label || 'Champ'; + + if (!raw) { + if (options.required) { + return { valid: false, message: label + ' est obligatoire.' }; + } + return { valid: true }; + } + + if (/[<>;&"]/g.test(raw)) { + return { valid: false, message: label + ' contient des caractères interdits (<, >, &, ;, ").' }; + } + + const pattern = options.pattern || /^[A-Za-zÀ-ÖØ-öø-ÿ0-9\s'.,:/()\-_]+$/; + if (!pattern.test(raw)) { + return { valid: false, message: label + ' contient des caractères non autorisés.' }; + } + + return { valid: true }; + } + + function validateNumberOrConsult(raw, options) { + const value = String(raw || '').trim(); + const lower = value.toLowerCase(); + + if (!value) { + if (options.required) { + return { valid: false, message: (options.label || 'Champ') + ' est obligatoire.' }; + } + return { valid: true }; + } + + if (lower === 'nous consulter') { + return { valid: true }; + } + + return validateNumeric(value, options); + } + + function evaluateRule(field, config) { + const profile = config.profile; + const label = pickFieldLabel(field, config.label); + const required = typeof config.requiredWhen === 'function' ? Boolean(config.requiredWhen(field)) : Boolean(config.required); + + let result; + switch (profile) { + case 'numeric': + result = validateNumeric(field.value, { + label: label, + required: required, + decimals: config.decimals, + min: config.min, + max: config.max, + positive: config.positive + }); + break; + case 'integer': + result = validateInteger(field.value, { + label: label, + required: required, + min: config.min, + max: config.max + }); + break; + case 'immat': + result = validateImmat(field.value, { + label: label, + required: required + }); + break; + case 'text': + result = validateTextSafe(field.value, { + label: label, + required: required, + pattern: config.pattern + }); + break; + case 'number_or_consulter': + result = validateNumberOrConsult(field.value, { + label: label, + required: required, + decimals: config.decimals, + min: config.min, + max: config.max, + positive: config.positive + }); + break; + default: + result = { valid: true }; + } + + if (result.normalized != null && config.normalize !== false && field.value !== result.normalized) { + field.value = result.normalized; + } + + return { + valid: result.valid, + message: result.message || '', + label: label + }; + } + + function createSummaryElement(summaryId) { + if (!summaryId) return null; + return document.getElementById(summaryId); + } + + function createGuard(options) { + const summary = createSummaryElement(options.summaryId); + const summaryTitle = String(options.summaryTitle || 'Impossible de continuer car :'); + const state = { + fieldRules: new Map(), + fieldErrors: new Map(), + externalChecks: new Map(), + targetButtons: [], + onChange: typeof options.onChange === 'function' ? options.onChange : null + }; + + const targetSelectors = Array.isArray(options.blockTargets) ? options.blockTargets : []; + targetSelectors.forEach(function (selector) { + document.querySelectorAll(selector).forEach(function (button) { + if (!button.dataset.rcOriginalDisabled) { + button.dataset.rcOriginalDisabled = button.disabled ? 'true' : 'false'; + } + state.targetButtons.push(button); + }); + }); + + function applySummary(messages) { + if (!summary) return; + if (!messages.length) { + summary.style.display = 'none'; + summary.innerHTML = ''; + return; + } + + const uniqueMessages = Array.from(new Set(messages)); + const list = uniqueMessages.map(function (msg) { + return '