(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 '
  • ' + msg + '
  • '; }).join(''); summary.innerHTML = [ '
    ' + summaryTitle + '
    ', '' ].join(''); summary.style.display = 'block'; } function applyBlocking(hasErrors) { state.targetButtons.forEach(function (button) { if (!button) return; if (hasErrors) { button.disabled = true; button.dataset.rcInvalid = 'true'; return; } if (button.dataset.rcInvalid === 'true') { button.dataset.rcInvalid = 'false'; button.disabled = button.dataset.rcOriginalDisabled === 'true'; } }); } function recompute() { const messages = []; state.fieldErrors.forEach(function (error) { if (error && error.message) { messages.push(error.message); } }); state.externalChecks.forEach(function (entry, key) { const checkResult = typeof entry.fn === 'function' ? entry.fn() : { valid: true }; if (!checkResult || checkResult.valid === false) { const message = checkResult && checkResult.message ? checkResult.message : entry.message || key; messages.push(message); } }); applySummary(messages); applyBlocking(messages.length > 0); if (state.onChange) { state.onChange(messages); } return messages; } function validateField(field) { const config = state.fieldRules.get(field); if (!config) return; if (!field || !field.isConnected || field.disabled) { if (config.errorSlot) setFieldError(field, config.errorSlot, ''); state.fieldErrors.delete(config.key); recompute(); return; } if (typeof config.activeWhen === 'function' && !config.activeWhen(field)) { setFieldError(field, config.errorSlot, ''); state.fieldErrors.delete(config.key); recompute(); return; } // Sécurité défaut: sans activeWhen explicite, on ignore un champ masqué. if (typeof config.activeWhen !== 'function' && field.type !== 'hidden' && field.offsetParent === null) { setFieldError(field, config.errorSlot, ''); state.fieldErrors.delete(config.key); recompute(); return; } const result = evaluateRule(field, config); setFieldError(field, config.errorSlot, result.valid ? '' : result.message); if (result.valid) { state.fieldErrors.delete(config.key); } else { state.fieldErrors.set(config.key, { message: result.message }); } recompute(); } function attachField(field, config) { if (!field) return; const key = config.key || (field.id ? field.id : (field.name ? field.name : ('field_' + state.fieldRules.size))); if (field.dataset && field.dataset.rcGuardAttached === 'true' && state.fieldRules.has(field)) return; const fieldConfig = Object.assign({}, config, { key: key, errorSlot: ensureErrorSlot(field, config.errorId) }); state.fieldRules.set(field, fieldConfig); if (field.dataset) { field.dataset.rcGuardAttached = 'true'; } const handler = function () { validateField(field); }; field.addEventListener('input', handler); field.addEventListener('change', handler); field.addEventListener('blur', handler); validateField(field); } function registerField(selectorOrElement, config) { if (!selectorOrElement || !config || !config.profile) return; if (typeof selectorOrElement === 'string') { document.querySelectorAll(selectorOrElement).forEach(function (element) { attachField(element, config); }); return; } attachField(selectorOrElement, config); } function observe(selector, config) { if (!selector || !config || !config.profile) return; registerField(selector, config); const observer = new MutationObserver(function () { registerField(selector, config); }); observer.observe(document.body, { childList: true, subtree: true }); return observer; } function registerExternal(key, fn, fallbackMessage) { if (!key || typeof fn !== 'function') return; state.externalChecks.set(key, { fn: fn, message: fallbackMessage || '' }); recompute(); } function refresh() { state.fieldRules.forEach(function (_cfg, field) { validateField(field); }); return recompute(); } return { registerField: registerField, observe: observe, registerExternal: registerExternal, refresh: refresh, formatFrenchAmount: formatFrenchAmount, parseLooseNumber: parseLooseNumber }; } window.RCValidationUtils = { createGuard: createGuard, parseLooseNumber: parseLooseNumber, formatFrenchAmount: formatFrenchAmount, syncFloatingLabels: syncFloatingLabels }; })();