Module Click&Fit avec upload topographies et gestion des photos
// ==UserScript==
// @name Click&Fit
// @namespace https://precilens.com/
// @version 5.9
// @description Module Click&Fit avec upload topographies et gestion des photos
// @author Precilens
// @match https://click-fit.precilens.com/*
// @icon https://www.precilens.fr/favicon.ico
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// Utilitaires pour les délais
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const delay = (fn, ms) => setTimeout(fn, ms);
// Constantes pour les délais courants
const DELAYS = {
SHORT: 100,
MEDIUM: 500,
LONG: 1000,
SAVE_WAIT: 1500, // Délai après sauvegarde pour laisser le serveur traiter
RETRY: 2000,
TOAST: 3000
};
// ============================================================
// DÉTECTION INTELLIGENTE FIN DE CALCUL (basé sur le code source Click&Fit)
// Au lieu d'attendre un délai fixe, on observe le loader Angular
// ============================================================
/**
* Attend que le calcul de lentille soit terminé
* Détecte la fin du calcul en observant :
* - La disparition du loader (amds-loader.loader)
* - La disparition des inputs en readonly
* @param {number} timeout - Timeout max en ms (défaut 10s)
* @returns {Promise<boolean>} true si calcul terminé, false si timeout
*/
async function waitForCalculationComplete(timeout = 10000) {
const start = Date.now();
// Petit délai initial pour laisser Angular démarrer le calcul
await wait(200);
while (Date.now() - start < timeout) {
// Chercher les indicateurs de calcul en cours
const loaders = document.querySelectorAll('amds-loader.loader, amds-loader[class*="loader"]');
const readonlyInputs = document.querySelectorAll('.lens-container input[readonly]');
const computingElements = document.querySelectorAll('[class*="computing"], [class*="loading"]:not(.not-loading)');
// Si aucun indicateur de calcul = terminé !
if (loaders.length === 0 && readonlyInputs.length === 0 && computingElements.length === 0) {
// Double vérification après 100ms (éviter les faux positifs)
await wait(100);
const loaders2 = document.querySelectorAll('amds-loader.loader, amds-loader[class*="loader"]');
const readonlyInputs2 = document.querySelectorAll('.lens-container input[readonly]');
if (loaders2.length === 0 && readonlyInputs2.length === 0) {
return true;
}
}
await wait(100); // Vérifie toutes les 100ms
}
console.warn('waitForCalculationComplete: timeout atteint');
return false;
}
/**
* Attend qu'un select soit prêt (avec ses options chargées)
* @param {string} selector - Sélecteur CSS du select
* @param {number} timeout - Timeout max en ms
* @returns {Promise<HTMLElement|null>}
*/
async function waitForSelectReady(selector, timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const select = document.querySelector(selector);
if (select && select.options && select.options.length > 1) {
return select;
}
await wait(100);
}
return document.querySelector(selector);
}
/**
* Attend que la page lentilles soit complètement chargée
* Détecte la présence des selects de type de lentille
* @param {number} timeout - Timeout max en ms
* @returns {Promise<boolean>}
*/
async function waitForLensPageReady(timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const rightSelect = document.querySelector('#input-righttype');
const leftSelect = document.querySelector('#input-lefttype');
// Page prête si les deux selects existent et ont des options
if (rightSelect && leftSelect &&
rightSelect.options && rightSelect.options.length > 0 &&
leftSelect.options && leftSelect.options.length > 0) {
// Petit délai pour laisser Angular finir le binding
await wait(100);
return true;
}
await wait(100);
}
console.warn('waitForLensPageReady: timeout atteint');
return false;
}
/**
* Attend que le calcul démarre (loader apparaît)
* Utile pour éviter de checker trop tôt
* @param {number} timeout - Timeout max en ms
* @returns {Promise<boolean>}
*/
async function waitForCalculationStart(timeout = 2000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const loaders = document.querySelectorAll('amds-loader.loader, amds-loader[class*="loader"]');
const readonlyInputs = document.querySelectorAll('.lens-container input[readonly]');
if (loaders.length > 0 || readonlyInputs.length > 0) {
return true; // Calcul démarré
}
await wait(50);
}
// Timeout = le calcul n'a peut-être pas démarré (ou était instantané)
return false;
}
// Système de gestion des observers
// Optimise la performance en nettoyant automatiquement
// les observers et intervals quand nécessaire
const ObserverManager = {
observers: new Map(),
intervals: new Map(),
currentPage: '',
// Créer et tracker un MutationObserver
createObserver(name, callback, target, options, persistent = false) {
// Nettoyer l'ancien observer s'il existe
if (this.observers.has(name)) {
this.observers.get(name).observer.disconnect();
}
const observer = new MutationObserver(callback);
observer.observe(target, options);
this.observers.set(name, {
observer,
persistent, // Si true, ne sera pas nettoyé lors du changement de page
target,
options
});
return observer;
},
// Créer et tracker un setInterval
createInterval(name, callback, delay, persistent = false) {
// Nettoyer l'ancien interval s'il existe
if (this.intervals.has(name)) {
clearInterval(this.intervals.get(name).id);
}
const id = setInterval(callback, delay);
this.intervals.set(name, {
id,
persistent,
callback,
delay
});
return id;
},
// Nettoyer les observers non-persistants
cleanupObservers() {
let cleaned = 0;
this.observers.forEach((data, name) => {
if (!data.persistent) {
data.observer.disconnect();
this.observers.delete(name);
cleaned++;
}
});
return cleaned;
},
// Nettoyer les intervals non-persistants
cleanupIntervals() {
let cleaned = 0;
this.intervals.forEach((data, name) => {
if (!data.persistent) {
clearInterval(data.id);
this.intervals.delete(name);
cleaned++;
}
});
return cleaned;
},
// Nettoyer tout lors du changement de page
cleanupOnPageChange() {
this.cleanupObservers();
this.cleanupIntervals();
},
// Nettoyer un observer spécifique
disconnect(name) {
if (this.observers.has(name)) {
this.observers.get(name).observer.disconnect();
this.observers.delete(name);
}
},
// Nettoyer un interval spécifique
clearInterval(name) {
if (this.intervals.has(name)) {
clearInterval(this.intervals.get(name).id);
this.intervals.delete(name);
}
},
// Stats de performance (debug)
getStats() {
return {
observers: this.observers.size,
intervals: this.intervals.size,
persistent: {
observers: Array.from(this.observers.values()).filter(d => d.persistent).length,
intervals: Array.from(this.intervals.values()).filter(d => d.persistent).length
}
};
}
};
// Détection automatique du changement de page pour cleanup
let lastUrl = window.location.href;
ObserverManager.createInterval('urlWatcher', () => {
if (window.location.href !== lastUrl) {
lastUrl = window.location.href;
ObserverManager.cleanupOnPageChange();
}
}, 1000, true); // persistent: surveille toujours les changements d'URL
// Double clique sur la consultation
document.addEventListener('dblclick', function(e) {
let el = e.target;
while (el && el !== document && (!el.id || !el.id.startsWith('file-'))) {
el = el.parentElement;
}
if (el && el.id && el.id.startsWith('file-')) {
const openBtn = document.querySelector(
'#wrapper > main > app-home > div > div:nth-child(3) > app-file-preview > div > div > amds-button.file-open-btn.hydrated > button'
);
if (openBtn) {
openBtn.click();
}
}
});
// Style CSS du fichier
function injectStyles() {
const styleId = 'click-fit-custom-styles';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.modal textarea#input-content {
min-height: 150px !important;
}
/* Agrandir la zone d'édition des notes existantes */
textarea#input-editContent {
min-height: 200px !important;
max-height: 500px !important;
resize: vertical !important;
font-size: 14px !important;
line-height: 1.4 !important;
padding: 12px !important;
border: 2px solid #e0e0e0 !important;
border-radius: 8px !important;
transition: border-color 0.3s ease, height 0.2s ease !important;
overflow: hidden !important;
box-sizing: border-box !important;
}
textarea#input-editContent:focus {
border-color: #2196f3 !important;
outline: none !important;
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1) !important;
}
/* Centrage du modal */
.modal.modal--size-medium {
position: fixed !important;
margin: 0 !important;
z-index: 1050 !important;
transform: none !important;
}
/* Bouton flottant + Menu */
.clickfit-fab {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #1e4b92 0%, #245aa8 100%);
border-radius: 50%;
box-shadow: 0 4px 15px rgba(30, 75, 146, 0.35);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.clickfit-fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(30, 75, 146, 0.55);
}
.clickfit-fab.active {
transform: rotate(45deg);
background: linear-gradient(135deg, #1a3d7c 0%, #1e4b92 100%);
}
.clickfit-fab-icon {
color: white;
font-size: 28px;
font-weight: bold;
transition: transform 0.3s ease;
}
.clickfit-fab-menu {
position: fixed;
bottom: 90px;
right: 20px;
display: flex;
flex-direction: column;
gap: 15px;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.clickfit-fab-menu.active {
opacity: 1;
visibility: visible;
}
.clickfit-fab-option {
background: white;
border-radius: 30px;
padding: 12px 20px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
transform: translateX(20px);
opacity: 0;
transition: all 0.3s ease;
}
.clickfit-fab-menu.active .clickfit-fab-option {
transform: translateX(0);
opacity: 1;
}
.clickfit-fab-menu.active .clickfit-fab-option:nth-child(1) {
transition-delay: 0.1s;
}
.clickfit-fab-menu.active .clickfit-fab-option:nth-child(2) {
transition-delay: 0.15s;
}
.clickfit-fab-menu.active .clickfit-fab-option:nth-child(3) {
transition-delay: 0.2s;
}
.clickfit-fab-menu.active .clickfit-fab-option:nth-child(4) {
transition-delay: 0.25s;
}
.clickfit-fab-menu.active .clickfit-fab-option:nth-child(5) {
transition-delay: 0.3s;
}
.clickfit-fab-menu.active .clickfit-fab-option:nth-child(6) {
transition-delay: 0.35s;
}
.clickfit-fab-option:hover {
transform: translateX(-5px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
}
.clickfit-fab-option-icon {
width: 24px;
height: 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
.clickfit-fab-option-text {
color: #333;
font-size: 14px;
font-weight: 500;
font-family: "Fira Sans", -apple-system, BlinkMacSystemFont;
}
/* notifications */
.clickfit-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: #333;
color: white;
padding: 12px 24px;
border-radius: 25px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
opacity: 0;
transition: all 0.3s ease;
z-index: 10000;
}
.clickfit-toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
/* INDICATEUR TOPOGRAPHIES */
.topo-indicator {
position: fixed;
bottom: 100px;
right: 20px;
width: 60px;
height: 60px;
background: #17a2b8;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(23, 162, 184, 0.3);
z-index: 9998;
}
.topo-indicator:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(23, 162, 184, 0.5);
}
.topo-indicator.has-files {
background: #28a745;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 4px 15px rgba(40, 167, 69, 0.4); }
50% { box-shadow: 0 4px 25px rgba(40, 167, 69, 0.6); }
100% { box-shadow: 0 4px 15px rgba(40, 167, 69, 0.4); }
}
.topo-badge {
position: absolute;
top: -5px;
right: -5px;
background: #dc3545;
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
display: none;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.topo-indicator.has-files .topo-badge {
display: flex;
}
/* Boite de dialog topos */
.topo-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 15px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
z-index: 10001;
min-width: 500px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: none;
}
.topo-dialog.show {
display: block;
animation: dialogSlideIn 0.3s ease;
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.topo-dialog-header {
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.topo-dialog-header h3 {
margin: 0;
font-size: 20px;
}
.topo-dialog-close {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.3s;
}
.topo-dialog-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.topo-dialog-body {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.topo-file-list {
margin: 15px 0;
}
.topo-file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
margin: 8px 0;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
transition: all 0.3s;
}
.topo-file-item:hover {
background: #e9ecef;
border-color: #17a2b8;
}
.topo-file-info {
display: flex;
align-items: center;
gap: 12px;
}
.topo-file-icon {
font-size: 24px;
}
.topo-file-details {
display: flex;
flex-direction: column;
}
.topo-file-name {
font-weight: 600;
color: #333;
}
.topo-file-meta {
font-size: 12px;
color: #6c757d;
}
.topo-file-status {
padding: 4px 12px;
border-radius: 15px;
font-size: 12px;
font-weight: 500;
}
.topo-file-status.pending {
background: #fff3cd;
color: #856404;
}
.topo-file-status.processing {
background: #cce5ff;
color: #004085;
}
.topo-file-status.success {
background: #d4edda;
color: #155724;
}
.topo-file-status.error {
background: #f8d7da;
color: #721c24;
}
.topo-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
.topo-actions-left {
display: flex;
align-items: center;
gap: 10px;
}
.topo-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.topo-btn-primary {
background: #17a2b8;
color: white;
}
.topo-btn-primary:hover {
background: #138496;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
}
.topo-btn-secondary {
background: #6c757d;
color: white;
}
.topo-btn-secondary:hover {
background: #5a6268;
}
.topo-connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6c757d;
}
.topo-connection-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #dc3545;
}
.topo-connection-dot.connected {
background: #28a745;
}
/* Modal medium: resize and overflow */
.modal.modal--size-medium {
resize: both !important;
overflow: auto !important;
min-width: 400px;
min-height: 200px;
max-width: 95vw !important;
max-height: 90vh !important;
}
/* Indicateur d'œil détecté */
.topo-file-item[data-eye="od"] {
border-left: 4px solid #17a2b8;
}
.topo-file-item[data-eye="og"] {
border-left: 4px solid #28a745;
}
.topo-file-item[data-eye="both"] {
border-left: 4px solid #ffc107;
}
.eye-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
margin-left: 8px;
}
.eye-badge.od {
background: #e3f2fd;
color: #1976d2;
}
.eye-badge.og {
background: #e8f5e9;
color: #388e3c;
}
.eye-badge.both {
background: #fff3e0;
color: #f57c00;
}
`;
document.head.appendChild(style);
}
// État de l'auto-save
let autoSaveEnabled = true;
// Afficher une notification
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'clickfit-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Fonction pour rendre le Header fixe, et avoir accès au boutons en permanence
function makeHeaderPermanentlyFixed() {
// Injecter le CSS pour le header fixe
const style = document.createElement('style');
style.id = 'permanent-sticky-header';
style.textContent = `
/* Header toujours fixe */
header[_ngcontent-jib-c63],
header {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 1000 !important;
background: white !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
}
/* Compenser la hauteur du header pour le contenu principal */
#wrapper > main,
main {
margin-top: 70px !important; /* Ajustez selon la hauteur réelle de votre header */
}
/* S'assurer que le header reste au-dessus */
.header__nav,
.header__team-selector,
.header__actions {
position: relative;
z-index: 1001;
}
`;
// Ajouter le style au document
if (!document.getElementById('permanent-sticky-header')) {
document.head.appendChild(style);
}
// Calculer et ajuster dynamiquement la hauteur si nécessaire
setTimeout(() => {
const header = document.querySelector('header');
if (header) {
const headerHeight = header.offsetHeight;
const main = document.querySelector('main') || document.querySelector('#wrapper > main');
if (main) {
main.style.marginTop = `${headerHeight}px`;
}
}
}, 100);
}
// Lancer immédiatement
makeHeaderPermanentlyFixed();
// Lancer aussi immédiatement les boutons de calcul
setTimeout(() => {
addCalculationButtonsToHeader();
}, 2000);
// Système de retry périodique pour les boutons de calcul
ObserverManager.createInterval('calcButtonsRetry', () => {
if (window.location.href.includes('/file/') && !document.querySelector('.calc-buttons-container')) {
addCalculationButtonsToHeader();
}
}, 5000, true);
// Détecter les changements d'URL pour les SPA
let currentUrl = window.location.href;
ObserverManager.createInterval('urlChangeDetector', () => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href;
// Attendre un peu que la page se charge puis essayer d'ajouter les boutons
setTimeout(() => {
if (currentUrl.includes('/file/')) {
addCalculationButtonsToHeader();
}
}, 1000);
}
}, 1000, true);
// Boutons de calcul Ortho K et LRPG
function addCalculationButtonsToHeader() {
// Vérifier qu'on est sur une page de dossier pour les boutons de calcul
const isFilePage = window.location.href.includes('/file/');
if (!isFilePage) {
}
// Debug: Lister tous les éléments du header
const allElements = document.querySelectorAll('header *, .header *');
allElements.forEach((el, index) => {
if (index < 20) { // Limiter à 20 éléments pour éviter le spam
}
});
// Styles pour les boutons
const style = document.createElement('style');
style.id = 'calc-buttons-styles';
style.textContent = `
.calc-buttons-container {
display: flex;
gap: 10px;
margin-left: 20px;
padding-left: 20px;
border-left: 2px solid #e0e0e0;
}
.calc-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: linear-gradient(135deg, #1e4b92 0%, #245aa8 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, 'Noto Sans', sans-serif;
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(30, 75, 146, 0.2);
}
.calc-button:hover {
background: #163a73;
transform: translateY(-1px);
}
.calc-button:active {
transform: translateY(0);
}
.calc-button.processing {
opacity: 0.7;
pointer-events: none;
}
`;
if (!document.getElementById('calc-buttons-styles')) {
document.head.appendChild(style);
}
// Fonction simple et directe pour sauvegarder
async function quickSave() {
// Forcer le blur sur l'élément actif pour valider
if (document.activeElement) {
document.activeElement.blur();
}
// Cliquer sur TOUS les boutons Enregistrer trouvés (visibles ou non)
const buttons = document.querySelectorAll('button');
let clickCount = 0;
for (const btn of buttons) {
if (btn.textContent?.includes('Enregistrer') && !btn.disabled) {
btn.click();
clickCount++;
}
}
if (clickCount > 0) {
}
// Attendre juste un peu pour la sauvegarde
await wait(500);
}
// Fonction LRPG - Version optimisée avec détection intelligente
async function performLRPGCalculation(button = null) {
// Feedback visuel si bouton fourni
if (button) {
button.classList.add('processing');
button.textContent = '⏳ Calcul...';
}
showToast('🔬 Démarrage du calcul LRPG...');
try {
// Commit le champ actif avec clic simulé pour déclencher validate() sur les input-dynamic
commitActiveField({ simulateClick: true });
// Aller sur l'onglet lentille
const lensTab = document.querySelector('[class*="lens-0-tab"]');
if (!lensTab) {
showToast('❌ Onglet lentille introuvable');
console.error('Onglet lentille non trouvé');
return;
}
lensTab.click();
// Attendre que la page lentilles soit prête (détection intelligente)
const pageReady = await waitForLensPageReady();
if (!pageReady) {
showToast('❌ Page lentilles non chargée');
return;
}
// Attendre que le select OD soit prêt avec ses options
const rightTypeSelect = await waitForSelectReady('#input-righttype');
if (rightTypeSelect) {
rightTypeSelect.value = 'lens:type:rigid';
rightTypeSelect.dispatchEvent(new Event('change', { bubbles: true }));
rightTypeSelect.dispatchEvent(new Event('input', { bubbles: true }));
// Attendre le démarrage du calcul puis sa fin
await waitForCalculationStart();
await waitForCalculationComplete();
} else {
console.error('Select OD non trouvé');
}
// Attendre que le select OG soit prêt
const leftTypeSelect = await waitForSelectReady('#input-lefttype');
if (leftTypeSelect) {
leftTypeSelect.value = 'lens:type:rigid';
leftTypeSelect.dispatchEvent(new Event('change', { bubbles: true }));
leftTypeSelect.dispatchEvent(new Event('input', { bubbles: true }));
// Attendre le démarrage du calcul puis sa fin
await waitForCalculationStart();
await waitForCalculationComplete();
} else {
console.error('Select OG non trouvé');
}
showToast('✅ Calcul LRPG terminé !');
} catch (error) {
console.error('Erreur lors du calcul LRPG:', error);
showToast('❌ Erreur lors du calcul LRPG');
} finally {
// Restaurer le bouton
if (button) {
button.classList.remove('processing');
button.innerHTML = 'LRPG';
}
}
}
// Créer les boutons
function createButtons() {
if (document.querySelector('.calc-buttons-container')) return;
if (!isFilePage) return; // Ne créer les boutons de calcul que sur les pages de dossier
// Essayer plusieurs sélecteurs pour trouver le header
const headerSelectors = [
'.header__actions',
'header .actions',
'header .header-actions',
'.header-actions',
'header',
'.header',
'[role="banner"]'
];
let headerActions = null;
for (const selector of headerSelectors) {
headerActions = document.querySelector(selector);
if (headerActions) {
break;
}
}
if (!headerActions) {
setTimeout(createButtons, 1000);
return;
}
const container = document.createElement('div');
container.className = 'calc-buttons-container';
// Bouton LRPG
const lrpgBtn = document.createElement('button');
lrpgBtn.className = 'calc-button';
lrpgBtn.innerHTML = 'LRPG';
lrpgBtn.onclick = () => performLRPGCalculation(lrpgBtn);
// Bouton Ortho-K
const orthoKBtn = document.createElement('button');
orthoKBtn.className = 'calc-button';
orthoKBtn.innerHTML = 'Ortho-K';
orthoKBtn.onclick = () => performOrthoKCalculation();
container.appendChild(lrpgBtn);
container.appendChild(orthoKBtn);
headerActions.insertBefore(container, headerActions.firstChild);
}
// Créer le bouton SplitView dans la navigation
function createSplitViewButton() {
if (document.querySelector('.splitview-button')) return;
// Chercher la navigation
const nav = document.querySelector('nav ul');
if (!nav) {
setTimeout(createSplitViewButton, 1000);
return;
}
// Créer le bouton SplitView
const splitViewLi = document.createElement('li');
splitViewLi.className = 'ng-star-inserted';
// Reproduire exactement la structure du bouton Accueil
const amdsButton = document.createElement('amds-button');
amdsButton.setAttribute('size', 'small');
amdsButton.setAttribute('shape', 'ghost');
amdsButton.setAttribute('color', 'primary-500');
amdsButton.setAttribute('elevation', 'elevation0');
amdsButton.setAttribute('type', 'button');
amdsButton.className = 'hydrated splitview-button';
const button = document.createElement('button');
button.className = 'amds-button amds-button-size-small amds-button-shape-ghost amds-elevation0 amds-color-primary-500';
button.type = 'button';
// Icône avec la même structure que Accueil
const icon = document.createElement('amds-icon');
icon.setAttribute('size', '24');
icon.setAttribute('color', 'primary-500');
icon.className = 'hydrated';
icon.innerHTML = '<i class="ri-sidebar-unfold-fill amds-icon amds-icon-size-24 amds-color-primary-500"></i>';
// Texte avec la même structure que Accueil
const text = document.createElement('amds-text');
text.setAttribute('font', 'button-large');
text.setAttribute('color', 'primary-500');
text.className = 'hydrated';
text.innerHTML = '<div class="amds-text amds-font-button-large amds-color-primary-500"> SplitView </div>';
button.appendChild(icon);
button.appendChild(text);
amdsButton.appendChild(button);
splitViewLi.appendChild(amdsButton);
// Insérer après le bouton Accueil
nav.appendChild(splitViewLi);
// Event listener sur le bouton interne ET sur le composant amds-button
function handleSplitViewClick(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Vérifier si la fonction existe
// Appeler la fonction SplitView existante
if (typeof activateSplitView === 'function') {
try {
activateSplitView();
} catch (error) {
console.error('Erreur lors de l\'activation SplitView:', error);
}
} else {
// Chercher la fonction dans window
if (typeof window.activateSplitView === 'function') {
window.activateSplitView();
} else {
// Toast de fallback
const toast = document.createElement('div');
toast.textContent = 'SplitView non disponible';
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #dc3545;
color: white;
padding: 12px 20px;
border-radius: 6px;
z-index: 10000;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
document.body.appendChild(toast);
delay(() => toast.remove(), DELAYS.TOAST);
}
}
}
// Ajouter l'event listener sur le bouton interne
button.addEventListener('click', handleSplitViewClick, true);
// Ajouter aussi l'event listener sur le composant amds-button au cas où
amdsButton.addEventListener('click', handleSplitViewClick, true);
// Ajouter l'event listener sur l'élément li aussi
splitViewLi.addEventListener('click', handleSplitViewClick, true);
}
// Démarrage
createButtons();
createSplitViewButton();
// Observer pour recréer si nécessaire
ObserverManager.createObserver(
'calcButtonsRecreate',
() => {
if (!document.querySelector('.calc-buttons-container') && window.location.href.includes('/file/')) {
createButtons();
}
if (!document.querySelector('.splitview-button')) {
createSplitViewButton();
}
},
document.body,
{ childList: true, subtree: true },
true // persistent: toujours actif
);
}
// Animation CSS
const styleAnimation = document.createElement('style');
styleAnimation.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(styleAnimation);
// Auto-démarrage
delay(addCalculationButtonsToHeader, DELAYS.LONG);
// --- Gestion de navigation SPA: supprimer/ajouter les boutons selon l'URL ---
function removeCalculationButtonsFromHeader() {
const container = document.querySelector('.calc-buttons-container');
if (container) container.remove();
const styleEl = document.getElementById('calc-buttons-styles');
if (styleEl) styleEl.remove();
}
function syncHeaderButtonsWithRoute() {
const onFile = window.location.href.includes('/file/');
const hasButtons = !!document.querySelector('.calc-buttons-container');
if (onFile && !hasButtons) {
addCalculationButtonsToHeader();
} else if (!onFile && hasButtons) {
removeCalculationButtonsFromHeader();
}
}
// Hooker les changements d'historique (SPA)
if (!window.__cfRouteHooked) {
window.__cfRouteHooked = true;
const _pushState = history.pushState;
history.pushState = function() {
const r = _pushState.apply(this, arguments);
setTimeout(syncHeaderButtonsWithRoute, 0);
return r;
};
const _replaceState = history.replaceState;
history.replaceState = function() {
const r = _replaceState.apply(this, arguments);
setTimeout(syncHeaderButtonsWithRoute, 0);
return r;
};
window.addEventListener('popstate', syncHeaderButtonsWithRoute);
window.addEventListener('hashchange', syncHeaderButtonsWithRoute);
}
// File d'attente périodique au cas où (sécurité)
ObserverManager.createInterval('syncHeaderButtons', syncHeaderButtonsWithRoute, 1500, true);
// Passage de l'astigmatisme en rouge si > 1,00 Dioptrie
function recolorAstigmatisme() {
// Chercher tous les éléments contenant "Astigmatisme interne"
const allElements = document.querySelectorAll('amds-text div, .amds-text div');
allElements.forEach(el => {
const txt = el.textContent.trim();
// Vérifier que c'est bien l'astigmatisme interne
if (!/Astigmatisme interne/i.test(txt)) return;
// Accepte différents formats : "1.25", "1,25", "+1.25", "-1.25", "1.25 /", etc.
const patterns = [
/Astigmatisme interne\s*[::]?\s*([-+−]?\d+[.,]\d+)/i,
/Astigmatisme interne\s*[::]?\s*([-+−]?\d+)/i,
/([-+−]?\d+[.,]\d+)\s*(?:D|dioptries)?/i
];
let value = null;
for (let pattern of patterns) {
const match = txt.match(pattern);
if (match) {
value = parseFloat(match[1].replace(',', '.').replace('−', '-'));
break;
}
}
if (value !== null && Math.abs(value) > 1.00) {
el.style.color = 'red';
el.style.fontWeight = 'bold';
} else {
el.style.color = '';
el.style.fontWeight = '';
}
});
}
// Observer avec debounce
let debounceTimer;
ObserverManager.createObserver(
'astigmatismeRecolor',
() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
recolorAstigmatisme();
}, 100);
},
document.body,
{
childList: true,
subtree: true,
characterData: true
},
true // persistent: toujours actif
);
// Exécution
recolorAstigmatisme();
ObserverManager.createInterval('astigmatismeInterval', recolorAstigmatisme, 2000, true);
// Bouton flottant - Import Topographie direct
function createFloatingButton() {
if (document.querySelector('.clickfit-fab')) return;
const fab = document.createElement('div');
fab.className = 'clickfit-fab';
fab.innerHTML = '<span class="clickfit-fab-icon">📁</span>';
fab.title = 'Import Topographies';
document.body.appendChild(fab);
// Clic direct sur le bouton flottant = Import Topographies
fab.addEventListener('click', async () => {
// Protection contre les doublons
if (window.DesktopImportModule && window.DesktopImportModule.isModalOpen) {
return;
}
// Attendre que le module soit initialisé
let attempts = 0;
while (!window.DesktopImportModule && attempts < 10) {
await wait(500);
attempts++;
}
if (window.DesktopImportModule) {
try {
await window.DesktopImportModule.showDesktopImportModal();
} catch (error) {
console.error('Erreur ouverture modal:', error);
// Toast d'erreur
const toast = document.createElement('div');
toast.textContent = 'Erreur ouverture modal d\'import';
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #dc3545;
color: white;
padding: 12px 20px;
border-radius: 6px;
z-index: 10000;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
document.body.appendChild(toast);
delay(() => toast.remove(), DELAYS.TOAST);
}
} else {
// Toast de fallback
const toast = document.createElement('div');
toast.textContent = 'Module d\'import non chargé - Rechargez la page';
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #dc3545;
color: white;
padding: 12px 20px;
border-radius: 6px;
z-index: 10000;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
document.body.appendChild(toast);
delay(() => toast.remove(), 5000);
}
});
}
(function setupAutoConsultationClick() {
function waitAndClick(selector, maxWait = 4000) {
return new Promise((resolve, reject) => {
const start = Date.now();
const interval = setInterval(() => {
const el = document.querySelector(selector);
if (el) {
el.click();
clearInterval(interval);
resolve(true);
} else if (Date.now() - start > maxWait) {
clearInterval(interval);
reject(`Timeout: ${selector} non trouvé`);
}
}, 100);
});
}
document.body.addEventListener('click', function(e) {
const tr = e.target.closest('#wearer-list-table tr.selectable');
if (!tr) return;
// Éviter double-trigger
if (tr.dataset.autoConsulting) return;
tr.dataset.autoConsulting = "1";
// Attendre Angular
setTimeout(async () => {
// Vérifier sélection
if (!tr.classList.contains('selected')) {
delete tr.dataset.autoConsulting;
return;
}
try {
await waitAndClick(
'#wrapper > main > app-home > div > div:nth-child(2) > app-files-list > div.files.hide-scrollbars.ng-star-inserted > app-file-card:nth-child(1)'
);
// showToast('Consultation ouverte !');
} catch (err) {
} finally {
// Reset
delete tr.dataset.autoConsulting;
}
}, 400);
}, true);
})();
// Module Import Topographies
const DesktopImportModule = {
apiUrl: 'http://localhost:8765/api',
currentGroups: [],
importedGroupIds: new Set(), // Track des groupes déjà importés par ID unique
init() {
// Reset du tracking à chaque init
this.importedGroupIds.clear();
},
// Génère un ID unique pour un groupe basé sur ses fichiers
getGroupId(group) {
if (!group || !group.files || group.files.length === 0) return null;
// Utiliser le premier fichier + l'œil sélectionné comme identifiant unique
const fileKey = group.files.sort().join('|');
const eye = group.selectedEye || 'unknown';
return `${fileKey}::${eye}`;
},
// Parser SaveIndex pour TMS5
async parseSaveIndexFile(filePath) {
try {
const response = await fetch(`${this.apiUrl}/file/${filePath}`);
if (!response.ok) {
console.error(`SaveIndex non trouvé: ${filePath}`);
return null;
}
const text = await response.text();
const lines = text.split('\n');
const eyes = {};
const infos = {};
// Parser les lignes de données (skip header - lignes 0, 1, 2)
for (let i = 3; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const columns = line.split(',');
if (columns.length < 10) continue;
const eye = columns[3].trim(); // Eye column
const lastName = columns[1] || '';
const firstName = columns[2] || '';
const date = columns[4] || '';
if (eye === 'OD' || eye === 'OS') {
// Stocker les informations patient
infos[eye] = {
lastName: lastName,
firstName: firstName,
date: date,
fullName: `${lastName} ${firstName}`.trim()
};
// Parser les valeurs kératométriques (format TMS5)
try {
// Ks (colonne 10) - en dioptries
if (columns.length > 10 && columns[10] && columns[10].trim()) {
infos[eye].ks = parseFloat(columns[10]);
}
// Ks Axis (colonne 11)
if (columns.length > 11 && columns[11] && columns[11].trim()) {
infos[eye].ksAxis = parseFloat(columns[11]);
}
// Kf (colonne 12) - en dioptries
if (columns.length > 12 && columns[12] && columns[12].trim()) {
infos[eye].kf = parseFloat(columns[12]);
}
// Kf Axis (colonne 13)
if (columns.length > 13 && columns[13] && columns[13].trim()) {
infos[eye].kfAxis = parseFloat(columns[13]);
}
// CYL (colonne 17) - en dioptries
if (columns.length > 17 && columns[17] && columns[17].trim()) {
infos[eye].cyl = parseFloat(columns[17]);
}
// Es (colonne 18)
if (columns.length > 18 && columns[18] && columns[18].trim()) {
infos[eye].es = parseFloat(columns[18]);
}
// Em (colonne 19)
if (columns.length > 19 && columns[19] && columns[19].trim()) {
infos[eye].em = parseFloat(columns[19]);
}
} catch (e) {
}
}
}
return { eyes, infos };
} catch (error) {
console.error(`Erreur parsing SaveIndex:`, error);
return null;
}
},
// Parser XREF pour TMS-4
async parseXrefFile(filePath) {
try {
const response = await fetch(`${this.apiUrl}/file/${filePath}`);
if (!response.ok) {
console.error(`XREF non trouvé: ${filePath}`);
return null;
}
const text = await response.text();
const lines = text.split('\n');
const eyes = {};
const infos = {};
// Parser chaque ligne (skip header - ligne 0 et 1)
for (let i = 2; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const columns = line.split(',');
if (columns.length < 8) continue;
const eye = columns[6].trim();
const filename = columns[7].trim();
// Extraire l'ID du fichier
let fileId;
if (filename.includes('\\')) {
fileId = filename.split('\\').pop().replace(/\.TMS$/i, '').replace(/\.tms$/i, '');
} else {
fileId = filename.replace(/\.TMS$/i, '').replace(/\.tms$/i, '');
}
if (eye === 'OD' || eye === 'OS') {
eyes[eye] = fileId;
// Stocker les informations patient (basé sur le format réel du XREF)
infos[eye] = {
lastName: columns[1] || '',
firstName: columns[2] || '',
date: columns[5] || '',
fullName: `${columns[1] || ''} ${columns[2] || ''}`.trim()
};
// Parser les valeurs SimK si disponibles (format réel du XREF)
try {
// SimK1 (colonne 8) - en mm, convertir en dioptries
if (columns.length > 8 && columns[8] && columns[8].trim()) {
const simk1Val = parseFloat(columns[8]);
if (simk1Val > 0) {
infos[eye].simk1 = Math.round(337.5 / simk1Val * 100) / 100;
}
}
// SimK1 Angle (colonne 9)
if (columns.length > 9 && columns[9] && columns[9].trim()) {
infos[eye].simk1Angle = parseFloat(columns[9]);
}
// SimK2 (colonne 10) - en mm, convertir en dioptries
if (columns.length > 10 && columns[10] && columns[10].trim()) {
const simk2Val = parseFloat(columns[10]);
if (simk2Val > 0) {
infos[eye].simk2 = Math.round(337.5 / simk2Val * 100) / 100;
}
}
// SimK2 Angle (colonne 11)
if (columns.length > 11 && columns[11] && columns[11].trim()) {
infos[eye].simk2Angle = parseFloat(columns[11]);
}
// MinK (colonne 12) - en mm, convertir en dioptries
if (columns.length > 12 && columns[12] && columns[12].trim()) {
const minkVal = parseFloat(columns[12]);
if (minkVal > 0) {
infos[eye].mink = Math.round(337.5 / minkVal * 100) / 100;
}
}
// CYL (colonne 14) - déjà en dioptries
if (columns.length > 14 && columns[14] && columns[14].trim()) {
infos[eye].cyl = parseFloat(columns[14]);
}
// Excentricités (colonnes 40 et 41)
if (columns.length > 40 && columns[40] && columns[40].trim()) {
infos[eye].es = parseFloat(columns[40]);
}
if (columns.length > 41 && columns[41] && columns[41].trim()) {
infos[eye].em = parseFloat(columns[41]);
}
} catch (e) {
}
}
}
return { eyes, infos };
} catch (error) {
console.error(`Erreur parsing XREF:`, error);
return null;
}
},
// Parsing du nom du fichier Pentacam pour extraire les informations patient
parsePentacamFilename(filePath) {
try {
const filename = filePath.split('/').pop().split('\\').pop();
// Format attendu: Nom_Prenom_OD/OS_Date_Heure.extension
// Exemple: Adam_Constantin_OD_28012020_084018.CUR
const pentacamPattern = /^(.+)_(.+)_(OD|OS)_(\d{8})_(\d{6})\.(cur|ele)$/i;
const match = filename.match(pentacamPattern);
if (!match) {
return null;
}
const [, lastName, firstName, eye, dateStr, timeStr] = match;
// Convertir la date (format DDMMYYYY)
const day = dateStr.substring(0, 2);
const month = dateStr.substring(2, 4);
const year = dateStr.substring(4, 8);
const date = `${day}/${month}/${year}`;
// Convertir l'heure (format HHMMSS)
const hours = timeStr.substring(0, 2);
const minutes = timeStr.substring(2, 4);
const seconds = timeStr.substring(4, 6);
const time = `${hours}:${minutes}:${seconds}`;
const fullName = `${lastName} ${firstName}`;
const fullDateTime = `${date} ${time}`;
const result = {
eyes: {
[eye.toLowerCase()]: {
lastName: lastName,
firstName: firstName,
date: fullDateTime,
fullName: fullName,
eye: eye,
filename: filename
}
},
infos: {
[eye.toLowerCase()]: {
lastName: lastName,
firstName: firstName,
date: fullDateTime,
fullName: fullName,
eye: eye,
filename: filename
}
}
};
return result;
} catch (error) {
console.error(`Erreur parsing Pentacam:`, error);
return null;
}
},
injectModalStyles() {
const oldStyle = document.getElementById('desktop-import-styles');
if (oldStyle) {
oldStyle.remove();
}
const style = document.createElement('style');
style.id = 'desktop-import-styles';
style.textContent = `
/* Overlay simple */
.dim-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 9999999;
}
.dim-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 10000000;
min-width: 800px;
max-width: 90vw;
max-height: 85vh;
overflow: hidden;
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, sans-serif;
}
.dim-header {
background: #1e4b92;
color: white;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
}
.dim-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.dim-close {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 4px;
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.dim-close:hover {
background: rgba(255, 255, 255, 0.1);
}
.dim-body {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
background: #fafafa;
}
.dim-body::-webkit-scrollbar {
width: 6px;
}
.dim-body::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dim-body::-webkit-scrollbar-thumb {
background: #1e4b92;
border-radius: 3px;
}
.dim-group {
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
transition: all 0.3s ease;
}
.dim-group:hover {
border-color: #ccc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dim-group strong {
font-weight: 500;
color: #333;
display: block;
margin-bottom: 8px;
}
.dim-group.selected-od {
border-color: rgba(186, 85, 211, 0.4);
background: rgba(186, 85, 211, 0.2);
box-shadow: 0 2px 8px rgba(186, 85, 211, 0.3);
}
.dim-group.selected-og {
border-color: rgba(135, 206, 250, 0.5);
background: rgba(135, 206, 250, 0.3);
box-shadow: 0 2px 8px rgba(135, 206, 250, 0.3);
}
.dim-group.selected-od * {
color: #333 !important;
}
.dim-group.selected-og * {
color: #333 !important;
}
.dim-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.dim-btn {
flex: 1;
padding: 8px 16px;
border: 1px solid #1e4b92;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 400;
transition: all 0.2s ease;
background: white;
color: #1e4b92;
}
.dim-btn:hover {
background: #1e4b92;
color: white;
}
.dim-btn.active-od {
background: rgba(186, 85, 211, 0.2);
color: #333;
border-color: rgba(186, 85, 211, 0.4);
}
.dim-btn.active-og {
background: rgba(135, 206, 250, 0.3);
color: #333;
border-color: rgba(135, 206, 250, 0.5);
}
.dim-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
background: white;
}
.dim-import-btn {
background: #1e4b92;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.dim-import-btn:hover {
background: #163a73;
}
.dim-import-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
document.head.appendChild(style);
},
async showDesktopImportModal() {
// Protection contre les doublons
if (this.isModalOpen) {
return;
}
this.isModalOpen = true;
try {
// Test connexion
const testResponse = await fetch(`${this.apiUrl}/status`);
if (!testResponse.ok) {
throw new Error('Scanner non accessible');
}
// Scanner tous les dossiers configurés dans l'application desktop
const response = await fetch(`${this.apiUrl}/scan-desktop`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Erreur scan');
}
if (!data.groups || data.groups.length === 0) {
this.isModalOpen = false; // Réinitialiser le flag AVANT l'alert
alert('Aucune topographie trouvée dans les dossiers configurés');
return;
}
// Stocker groupes
this.currentGroups = data.groups;
// Créer modal
this.createSimpleModal();
} catch (error) {
this.isModalOpen = false; // Réinitialiser le flag AVANT l'alert
alert('Erreur: Vérifiez que le scanner Python est lancé\n\n' + error.message);
}
},
createSimpleModal() {
// Nettoyer modal
this.closeModal();
// S'assurer que les styles sont injectés
this.injectModalStyles();
// Overlay
const overlay = document.createElement('div');
overlay.className = 'dim-overlay';
overlay.id = 'dim-overlay';
// Modal
const modal = document.createElement('div');
modal.className = 'dim-modal';
modal.id = 'dim-modal';
// HTML
let groupsHtml = '';
this.currentGroups.forEach((group, index) => {
// Chercher le fichier XREF pour TMS4
const xrefFile = group.files.find(f => f.toLowerCase().includes('xref'));
// Chercher le fichier SaveIndex pour TMS5
const saveIndexFile = group.files.find(f => f.toLowerCase().includes('saveindex'));
// Pour TMS4 avec XREF, afficher seulement la carte des détails
if (xrefFile && group.topographer === 'tms4') {
groupsHtml += `
<div class="dim-group" data-index="${index}">
<strong>${group.icon} ${group.topographer_name}</strong>
<div class="dim-xref-info" data-xref="${xrefFile}" style="
background: transparent;
border: none;
border-radius: 6px;
padding: 12px;
margin: 12px 0;
font-size: 13px;
">
<div style="color: #1e4b92; font-weight: 500; margin-bottom: 8px;">
📋 Données XREF TMS-4 détectées
</div>
<div class="dim-patient-info" style="color: #333;">
Chargement des informations patient...
</div>
</div>
<div class="dim-buttons">
<button class="dim-btn dim-select-od" data-index="${index}" data-eye="od">
OD
</button>
<button class="dim-btn dim-select-og" data-index="${index}" data-eye="og">
OG
</button>
<button class="dim-btn dim-select-skip" data-index="${index}" data-eye="skip">
Ignorer
</button>
</div>
</div>
`;
} else if (saveIndexFile && group.topographer === 'tms5') {
// Pour TMS5 avec SaveIndex, afficher seulement la carte des détails
groupsHtml += `
<div class="dim-group" data-index="${index}">
<strong>${group.icon} ${group.topographer_name}</strong>
<div class="dim-saveindex-info" data-saveindex="${saveIndexFile}" style="
background: transparent;
border: none;
border-radius: 6px;
padding: 12px;
margin: 12px 0;
font-size: 13px;
">
<div style="color: #1e4b92; font-weight: 500; margin-bottom: 8px;">
📋 Données TMS-5 détectées
</div>
<div class="dim-patient-info" style="color: #333;">
Chargement des informations patient...
</div>
</div>
<div class="dim-buttons">
<button class="dim-btn dim-select-od" data-index="${index}" data-eye="od">
OD
</button>
<button class="dim-btn dim-select-og" data-index="${index}" data-eye="og">
OG
</button>
<button class="dim-btn dim-select-skip" data-index="${index}" data-eye="skip">
Ignorer
</button>
</div>
</div>
`;
} else if (group.topographer === 'pentacam') {
// Pour Pentacam, parser le nom de fichier pour extraire les infos patient
const pentacamFile = group.files[0]; // Prendre le premier fichier
groupsHtml += `
<div class="dim-group" data-index="${index}">
<strong>${group.icon} ${group.topographer_name}</strong>
<div class="dim-pentacam-info" data-pentacam="${pentacamFile}" style="
background: transparent;
border: none;
border-radius: 6px;
padding: 12px;
margin: 12px 0;
font-size: 13px;
">
<div style="color: #1e4b92; font-weight: 500; margin-bottom: 8px;">
📋 Données Pentacam détectées
</div>
<div class="dim-patient-info" style="color: #333;">
Chargement des informations patient...
</div>
</div>
<div class="dim-buttons">
<button class="dim-btn dim-select-od" data-index="${index}" data-eye="od">
OD
</button>
<button class="dim-btn dim-select-og" data-index="${index}" data-eye="og">
OG
</button>
<button class="dim-btn dim-select-skip" data-index="${index}" data-eye="skip">
Ignorer
</button>
</div>
</div>
`;
} else {
// Pour les autres topographes, afficher la liste des fichiers
groupsHtml += `
<div class="dim-group" data-index="${index}">
<strong>${group.icon} ${group.topographer_name}</strong>
<div style="font-size: 12px; color: #666; margin: 5px 0;">
${group.files.map(f => '• ' + f.split('\\').pop()).join('<br>')}
</div>
<div class="dim-buttons">
<button class="dim-btn dim-select-od" data-index="${index}" data-eye="od">
OD
</button>
<button class="dim-btn dim-select-og" data-index="${index}" data-eye="og">
OG
</button>
<button class="dim-btn dim-select-skip" data-index="${index}" data-eye="skip">
Ignorer
</button>
</div>
</div>
`;
}
});
modal.innerHTML = `
<div class="dim-header">
<h3>Import depuis le bureau (${this.currentGroups.length} groupes)</h3>
<button class="dim-close" id="dim-close">×</button>
</div>
<div class="dim-body">
${groupsHtml}
</div>
<div class="dim-footer">
<button class="dim-import-btn" id="dim-import">
Lancer l'import
</button>
</div>
`;
// Ajouter DOM
document.body.appendChild(overlay);
document.body.appendChild(modal);
// Forcer l'affichage du modal
setTimeout(() => {
const modal = document.querySelector('.dim-modal');
const overlay = document.querySelector('.dim-overlay');
if (modal) {
modal.style.display = 'block';
modal.style.visibility = 'visible';
modal.style.opacity = '1';
}
if (overlay) {
overlay.style.display = 'block';
overlay.style.visibility = 'visible';
}
}, 100);
// Attacher événements
this.attachEvents();
// Parser et afficher les informations XREF
this.loadXrefData();
},
// Charger les données XREF et SaveIndex pour tous les groupes
async loadXrefData() {
// Charger les données XREF pour TMS4
// OPTIMISATION: Utiliser patient_info du backend (plus fiable et évite le double parsing)
const xrefElements = document.querySelectorAll('.dim-xref-info');
for (const element of xrefElements) {
const patientInfoEl = element.querySelector('.dim-patient-info');
const groupIndex = parseInt(element.closest('.dim-group').dataset.index);
const currentGroup = this.currentGroups[groupIndex];
if (!patientInfoEl || !currentGroup) continue;
const xrefNumber = currentGroup.xref_number;
let patientHtml = '';
// PRIORITÉ 1: Utiliser patient_info fourni par le backend (déjà filtré par œil)
if (currentGroup.patient_info && currentGroup.patient_info.eye) {
const backendEye = currentGroup.patient_info.eye.toUpperCase();
// Convertir OS -> OG pour l'affichage français
const displayEye = backendEye === 'OS' ? 'OG' : (backendEye === 'OD' ? 'OD' : backendEye);
const isRightEye = displayEye === 'OD';
const eyeLabel = isRightEye ? 'Œil Droit' : 'Œil Gauche';
const patientInfo = currentGroup.patient_info;
// Stocker pour utilisation ultérieure
this.currentGroups[groupIndex].parsedData = {
lastName: patientInfo.last_name || '',
firstName: patientInfo.first_name || '',
date: patientInfo.date_time || '',
fullName: patientInfo.full_name || ''
};
patientHtml = `
<div style="
margin-bottom: 12px;
padding: 10px;
background: ${isRightEye ? 'rgba(186, 85, 211, 0.2)' : 'rgba(135, 206, 250, 0.3)'};
border-radius: 6px;
border-left: none;
">
<div style="display: flex; align-items: center; margin-bottom: 6px;">
<span style="font-size: 16px; margin-right: 8px;">👁️</span>
<strong style="color: ${isRightEye ? '#663399' : '#2196f3'}; font-size: 14px;">
${eyeLabel} (${displayEye}) - XREF #${xrefNumber}
</strong>
</div>
<div style="margin-bottom: 4px;">
<strong style="color: #333;">👤 Nom:</strong> <span style="color: #555;">${patientInfo.last_name || 'N/A'}</span>
</div>
<div style="margin-bottom: 4px;">
<strong style="color: #333;">👤 Prénom:</strong> <span style="color: #555;">${patientInfo.first_name || 'N/A'}</span>
</div>
</div>
`;
patientInfoEl.innerHTML = patientHtml;
continue; // Passer au groupe suivant, pas besoin de parser le XREF
}
// PRIORITÉ 2: Fallback - Parser le fichier XREF si patient_info absent
const xrefFile = element.dataset.xref;
if (xrefFile) {
try {
const xrefData = await this.parseXrefFile(xrefFile);
if (xrefData && xrefData.infos) {
// Trouver l'œil correspondant au xref_number
let matchedEye = null;
let matchedInfo = null;
for (const [eye, fileId] of Object.entries(xrefData.eyes)) {
if (fileId === xrefNumber) {
matchedEye = eye;
matchedInfo = xrefData.infos[eye];
break;
}
}
if (matchedEye && matchedInfo) {
const isRightEye = matchedEye === 'OD';
const eyeLabel = isRightEye ? 'Œil Droit' : 'Œil Gauche';
this.currentGroups[groupIndex].parsedData = matchedInfo;
patientHtml = `
<div style="
margin-bottom: 12px;
padding: 10px;
background: ${isRightEye ? 'rgba(186, 85, 211, 0.2)' : 'rgba(135, 206, 250, 0.3)'};
border-radius: 6px;
border-left: none;
">
<div style="display: flex; align-items: center; margin-bottom: 6px;">
<span style="font-size: 16px; margin-right: 8px;">👁️</span>
<strong style="color: ${isRightEye ? '#663399' : '#2196f3'}; font-size: 14px;">
${eyeLabel} (${matchedEye}) - XREF #${xrefNumber}
</strong>
</div>
<div style="margin-bottom: 4px;">
<strong style="color: #333;">👤 Nom:</strong> <span style="color: #555;">${matchedInfo.lastName || 'N/A'}</span>
</div>
<div style="margin-bottom: 4px;">
<strong style="color: #333;">👤 Prénom:</strong> <span style="color: #555;">${matchedInfo.firstName || 'N/A'}</span>
</div>
</div>
`;
} else {
patientHtml = `<span style="color: #dc3545;">Œil non trouvé pour XREF #${xrefNumber}</span>`;
}
} else {
patientHtml = '<span style="color: #666;">Aucune donnée patient trouvée</span>';
}
} catch (error) {
console.error('Erreur chargement XREF:', error);
patientHtml = '<span style="color: #dc3545;">Erreur de chargement</span>';
}
} else {
patientHtml = '<span style="color: #666;">Aucune donnée XREF disponible</span>';
}
patientInfoEl.innerHTML = patientHtml;
}
// Charger les données SaveIndex pour TMS5
const saveIndexElements = document.querySelectorAll('.dim-saveindex-info');
for (const element of saveIndexElements) {
const saveIndexFile = element.dataset.saveindex;
const patientInfoEl = element.querySelector('.dim-patient-info');
if (saveIndexFile && patientInfoEl) {
try {
// Parser le fichier SaveIndex
const saveIndexData = await this.parseSaveIndexFile(saveIndexFile);
if (saveIndexData && saveIndexData.infos) {
// Stocker les données dans le groupe correspondant
const groupIndex = parseInt(element.closest('.dim-group').dataset.index);
if (groupIndex !== undefined && this.currentGroups[groupIndex]) {
// Prendre les données du premier œil trouvé (OD ou OS)
const firstEyeData = Object.values(saveIndexData.infos)[0];
if (firstEyeData) {
this.currentGroups[groupIndex].parsedData = firstEyeData;
}
}
// Afficher les informations patient
let patientHtml = '';
Object.entries(saveIndexData.infos).forEach(([eye, info]) => {
const eyeIcon = eye === 'OD' ? '👁️' : '👁️';
const eyeLabel = eye === 'OD' ? 'Œil Droit' : 'Œil Gauche';
patientHtml += `
<div style="
margin-bottom: 12px;
padding: 10px;
background: ${eye === 'OD' ? 'rgba(186, 85, 211, 0.2)' : 'rgba(135, 206, 250, 0.3)'};
border-radius: 6px;
border-left: none;
">
<div style="display: flex; align-items: center; margin-bottom: 6px;">
<span style="font-size: 16px; margin-right: 8px;">${eyeIcon}</span>
<strong style="color: ${eye === 'OD' ? '#663399' : '#2196f3'}; font-size: 14px;">
${eyeLabel} (${eye})
</strong>
</div>
<div style="margin-bottom: 4px;">
<strong>👤 Nom:</strong> ${info.lastName || 'N/A'}
</div>
<div style="margin-bottom: 4px;">
<strong>👤 Prénom:</strong> ${info.firstName || 'N/A'}
</div>
</div>
`;
});
patientInfoEl.innerHTML = patientHtml;
} else {
patientInfoEl.innerHTML = '<span style="color: #666;">Aucune donnée patient trouvée</span>';
}
} catch (error) {
console.error('Erreur chargement SaveIndex:', error);
patientInfoEl.innerHTML = '<span style="color: #dc3545;">Erreur de chargement</span>';
}
}
}
// Charger les données Pentacam pour tous les groupes
const pentacamElements = document.querySelectorAll('.dim-pentacam-info');
for (const element of pentacamElements) {
const pentacamFile = element.dataset.pentacam;
const patientInfoEl = element.querySelector('.dim-patient-info');
if (pentacamFile && patientInfoEl) {
try {
// Parser le nom de fichier Pentacam
const pentacamData = await this.parsePentacamFilename(pentacamFile);
if (pentacamData && pentacamData.infos) {
// Stocker les données dans le groupe correspondant
const groupIndex = parseInt(element.closest('.dim-group').dataset.index);
if (groupIndex !== undefined && this.currentGroups[groupIndex]) {
const firstEyeData = Object.values(pentacamData.infos)[0];
if (firstEyeData) {
this.currentGroups[groupIndex].parsedData = firstEyeData;
}
}
// Afficher les informations patient
let patientHtml = '';
Object.entries(pentacamData.infos).forEach(([eye, info]) => {
const eyeIcon = '👁️';
const eyeLabel = eye === 'od' ? 'Œil Droit' : 'Œil Gauche';
patientHtml += `
<div style="
margin-bottom: 12px;
padding: 10px;
background: ${eye === 'od' ? 'rgba(186, 85, 211, 0.2)' : 'rgba(135, 206, 250, 0.3)'};
border-radius: 6px;
border-left: none;
">
<div style="display: flex; align-items: center; margin-bottom: 6px;">
<span style="font-size: 16px; margin-right: 8px;">${eyeIcon}</span>
<strong style="color: ${eye === 'od' ? '#663399' : '#2196f3'}; font-size: 14px;">
${eyeLabel} (${eye.toUpperCase()})
</strong>
</div>
<div style="margin-bottom: 4px;">
<strong>👤 Nom:</strong> ${info.lastName || 'N/A'}
</div>
<div style="margin-bottom: 4px;">
<strong>👤 Prénom:</strong> ${info.firstName || 'N/A'}
</div>
</div>
`;
});
patientInfoEl.innerHTML = patientHtml;
} else {
patientInfoEl.innerHTML = '<span style="color: #666;">Aucune donnée patient trouvée</span>';
}
} catch (error) {
console.error('Erreur chargement Pentacam:', error);
patientInfoEl.innerHTML = '<span style="color: #dc3545;">Erreur de chargement</span>';
}
}
}
},
attachEvents() {
const modal = document.getElementById('dim-modal');
if (!modal) {
console.error('Modal non trouvé !');
return;
}
// Fermer modal
document.getElementById('dim-close')?.addEventListener('click', () => {
this.closeModal();
});
document.getElementById('dim-overlay')?.addEventListener('click', () => {
this.closeModal();
});
// Sélection
modal.querySelectorAll('.dim-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(btn.dataset.index);
const eye = btn.dataset.eye;
this.selectGroupEye(index, eye);
});
});
// Tout OD
document.getElementById('dim-all-od')?.addEventListener('click', () => {
this.currentGroups.forEach((g, i) => this.selectGroupEye(i, 'od'));
});
// Tout OG
document.getElementById('dim-all-og')?.addEventListener('click', () => {
this.currentGroups.forEach((g, i) => this.selectGroupEye(i, 'og'));
});
// Import
document.getElementById('dim-import')?.addEventListener('click', () => {
this.startImport();
});
// ESC
document.addEventListener('keydown', this.escHandler = (e) => {
if (e.key === 'Escape') this.closeModal();
});
},
selectGroupEye(index, eye) {
const group = this.currentGroups[index];
if (!group) return;
// Mettre à jour
group.selectedEye = eye;
// UI
const groupEl = document.querySelector(`.dim-group[data-index="${index}"]`);
if (groupEl) {
// Reset
groupEl.classList.remove('selected-od', 'selected-og');
groupEl.querySelectorAll('.dim-btn').forEach(b => {
b.classList.remove('active-od', 'active-og');
});
// Classes
if (eye === 'od') {
groupEl.classList.add('selected-od');
groupEl.querySelector('.dim-select-od').classList.add('active-od');
} else if (eye === 'og') {
groupEl.classList.add('selected-og');
groupEl.querySelector('.dim-select-og').classList.add('active-og');
}
}
},
closeModal() {
document.getElementById('dim-modal')?.remove();
document.getElementById('dim-overlay')?.remove();
if (this.escHandler) {
document.removeEventListener('keydown', this.escHandler);
}
// Réinitialiser le flag de modal ouvert
this.isModalOpen = false;
},
async startImport() {
const toImport = this.currentGroups.filter(g => g.selectedEye && g.selectedEye !== 'skip');
if (toImport.length === 0) {
alert('Sélectionnez au moins un œil pour l\'import');
return;
}
// Fermer modal
this.closeModal();
// Set des indices déjà traités (approche plus robuste que les IDs)
const processedIndices = new Set();
// Toast
if (window.showToast) {
window.showToast(`🚀 Import de ${toImport.length} groupe(s) en cours...`);
}
let successCount = 0;
let errorCount = 0;
// Import séquentiel
for (let i = 0; i < toImport.length; i++) {
// Skip si cet index a déjà été traité (comme sibling d'un groupe précédent)
if (processedIndices.has(i)) {
console.log(`[ClickFit] Groupe ${toImport[i].topographer_name} (${toImport[i].selectedEye}) déjà traité comme sibling, skip`);
continue;
}
const group = toImport[i];
// Skip si ce groupe a déjà été importé (par autoCreateWearerAndFile)
if (group.alreadyImported || this.importedGroupIds.has(this.getGroupId(group))) {
console.log(`[ClickFit] Groupe ${group.topographer_name} (${group.selectedEye}) déjà importé, skip`);
continue;
}
// AVANT de traiter ce groupe, trouver son sibling dans toImport et le marquer comme traité
// car autoCreateWearerAndFile va l'importer automatiquement
console.log(`[ClickFit] Analyse groupe ${i}: ${group.topographer_name} (${group.selectedEye}), parsedData: ${group.parsedData ? 'OUI' : 'NON'}, patient_info: ${group.patient_info ? 'OUI' : 'NON'}`);
for (let j = 0; j < toImport.length; j++) {
if (j === i) continue; // Ignorer le groupe courant
if (processedIndices.has(j)) continue; // Déjà traité
const otherGroup = toImport[j];
// Même œil sélectionné = pas un sibling
if (otherGroup.selectedEye === group.selectedEye) continue;
let isSibling = false;
// Méthode 1: Comparer via parsedData (nom du patient)
if (group.parsedData && otherGroup.parsedData) {
const patientName = `${group.parsedData.lastName || ''}_${group.parsedData.firstName || ''}`.toLowerCase().trim();
const otherPatientName = `${otherGroup.parsedData.lastName || ''}_${otherGroup.parsedData.firstName || ''}`.toLowerCase().trim();
if (patientName && otherPatientName && otherPatientName === patientName) {
isSibling = true;
console.log(`[ClickFit] Sibling via parsedData: "${patientName}"`);
}
}
// Méthode 2: Comparer via patient_info du backend
if (!isSibling && group.patient_info && otherGroup.patient_info) {
const sameName = (group.patient_info.last_name === otherGroup.patient_info.last_name) &&
(group.patient_info.first_name === otherGroup.patient_info.first_name);
if (sameName) {
isSibling = true;
console.log(`[ClickFit] Sibling via patient_info: ${group.patient_info.last_name}`);
}
}
// Méthode 3: Même topographe = probablement siblings (même session d'import)
if (!isSibling && group.topographer === otherGroup.topographer) {
isSibling = true;
console.log(`[ClickFit] Sibling via même topographe: ${group.topographer}`);
}
if (isSibling) {
console.log(`[ClickFit] >>> Pré-marquage sibling index ${j}: ${otherGroup.topographer_name} (${otherGroup.selectedEye})`);
processedIndices.add(j);
}
}
try {
// Progression
if (window.showToast) {
window.showToast(`Import ${i + 1}/${toImport.length}: ${group.topographer_name} → ${group.selectedEye.toUpperCase()}`);
}
// Attendre import
// Si on est déjà sur une page /file/, on importe pour le patient actuel (isSiblingImport = true)
const isOnFilePage = window.location.href.includes('/file/');
await this.performRealImport(group, group.selectedEye, isOnFilePage);
successCount++;
// Pause
if (i < toImport.length - 1) {
await wait(2000);
}
} catch (error) {
console.error(`Erreur import ${group.topographer_name}:`, error);
errorCount++;
}
}
// Rafraîchir
if (window.TopographyModule) {
window.TopographyModule.checkForFiles();
}
},
async performRealImport(group, eye, isSiblingImport = false) {
const isHomePage = window.location.href === 'https://click-fit.precilens.com/' ||
window.location.href === 'https://click-fit.precilens.com';
// Si on est sur la page d'accueil, créer le porteur et importer
if (isHomePage) {
await this.autoCreateWearerAndFile(group);
return; // Sortir APRÈS la création automatique - IMPORTANT !
}
// Si on est sur une page /file/ mais qu'on doit créer un nouveau porteur
// (patient différent), retourner à la page d'accueil
// SAUF si c'est un import de sibling (même patient, autre œil)
const isFilePage = window.location.href.includes('/file/');
if (isFilePage && !isSiblingImport) {
// Ce n'est pas un sibling, donc c'est un nouveau patient
// On doit retourner à la page d'accueil pour créer le nouveau porteur
console.log('[ClickFit] Nouveau patient détecté, retour à la page d\'accueil pour créer le porteur...');
window.location.href = 'https://click-fit.precilens.com/';
// Attendre que la page se charge
await new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (window.location.href === 'https://click-fit.precilens.com/' ||
window.location.href === 'https://click-fit.precilens.com') {
clearInterval(checkInterval);
resolve();
}
}, 100);
// Timeout de sécurité
setTimeout(() => {
clearInterval(checkInterval);
resolve();
}, 10000);
});
// Attendre que l'interface soit prête
await wait(DELAYS.SAVE_WAIT);
// Maintenant créer le porteur
await this.autoCreateWearerAndFile(group);
return;
}
try {
// Fonction helper pour trouver le bouton d'upload
const findUploadButton = (targetEye) => {
console.log(`[ClickFit] Recherche bouton upload pour œil: ${targetEye}`);
let btn = null;
// Méthode 1: Chercher par le texte "OD" ou "OG" dans le header de la section
const eyeSections = document.querySelectorAll('app-file-information-eye');
console.log(`[ClickFit] ${eyeSections.length} section(s) app-file-information-eye trouvée(s)`);
for (const section of eyeSections) {
const headerText = section.textContent || '';
const isOD = headerText.includes('Œil droit') || headerText.includes('OD') || headerText.includes('Oeil droit');
const isOG = headerText.includes('Œil gauche') || headerText.includes('OG') || headerText.includes('Oeil gauche') || headerText.includes('OS');
console.log(`[ClickFit] Section trouvée - OD: ${isOD}, OG: ${isOG}`);
if ((targetEye === 'od' && isOD) || (targetEye === 'og' && isOG)) {
// Chercher le bouton d'import dans cette section
btn = section.querySelector('button i.ri-download-2-fill')?.parentElement?.parentElement;
if (!btn) {
btn = section.querySelector('button:has(i.ri-download-2-fill)');
}
if (!btn) {
// Chercher n'importe quel bouton avec l'icône download
const btns = section.querySelectorAll('button');
for (const b of btns) {
if (b.querySelector('i.ri-download-2-fill') || b.querySelector('[class*="download"]')) {
btn = b;
break;
}
}
}
if (btn) {
console.log(`[ClickFit] Bouton trouvé via texte header pour ${targetEye}`);
break;
}
}
}
// Méthode 2 (fallback): Par position nth-child
if (!btn) {
console.log(`[ClickFit] Fallback: recherche par nth-child pour ${targetEye}`);
if (targetEye === 'od') {
btn = document.querySelector('app-file-information-eye:nth-child(1) button i.ri-download-2-fill')?.parentElement?.parentElement;
if (!btn) {
btn = document.querySelector('app-file-information-eye:nth-child(1) button:has(i.ri-download-2-fill)');
}
} else {
btn = document.querySelector('app-file-information-eye:nth-child(2) button i.ri-download-2-fill')?.parentElement?.parentElement;
if (!btn) {
btn = document.querySelector('app-file-information-eye:nth-child(2) button:has(i.ri-download-2-fill)');
}
}
}
// Méthode 3 (dernier recours): Tous les boutons avec icône download
if (!btn) {
console.log(`[ClickFit] Dernier recours: recherche globale des boutons download`);
const allButtons = document.querySelectorAll('button');
const downloadButtons = Array.from(allButtons).filter(b =>
b.querySelector('i.ri-download-2-fill')
);
console.log(`[ClickFit] ${downloadButtons.length} bouton(s) download trouvé(s) globalement`);
if (targetEye === 'od' && downloadButtons[0]) {
btn = downloadButtons[0];
} else if (targetEye === 'og' && downloadButtons[1]) {
btn = downloadButtons[1];
}
}
if (btn) {
console.log(`[ClickFit] ✓ Bouton trouvé pour ${targetEye}:`, btn);
} else {
console.error(`[ClickFit] ✗ Bouton NON trouvé pour ${targetEye}`);
}
return btn;
};
// Chercher le bouton avec retry (max 5 tentatives)
let uploadButton = null;
for (let attempt = 0; attempt < 5; attempt++) {
uploadButton = findUploadButton(eye);
if (uploadButton) {
break;
}
console.log(`[ClickFit] Bouton ${eye} non trouvé, tentative ${attempt + 1}/5...`);
await wait(1000);
}
if (!uploadButton) {
console.error(`Bouton ${eye} non trouvé après 5 tentatives`);
// Debug: Afficher tous les boutons de la page
console.log('[ClickFit] DEBUG - Tous les boutons de la page:');
document.querySelectorAll('button').forEach((b, i) => {
console.log(` [${i}] ${b.className} | innerHTML: ${b.innerHTML.substring(0, 100)}`);
});
console.log('[ClickFit] DEBUG - Toutes les sections eye:');
document.querySelectorAll('app-file-information-eye').forEach((s, i) => {
console.log(` [${i}] textContent: ${s.textContent.substring(0, 200)}`);
});
alert(`Bouton d'import ${eye.toUpperCase()} non trouvé. Voir console (F12) pour debug.`);
return;
}
console.log(`[ClickFit] Clic sur le bouton upload pour ${eye}...`);
console.log(`[ClickFit] Bouton className: ${uploadButton.className}`);
console.log(`[ClickFit] Bouton disabled: ${uploadButton.disabled}`);
console.log(`[ClickFit] Bouton visible: ${uploadButton.offsetParent !== null}`);
// Vérifier si le bouton est désactivé
if (uploadButton.disabled) {
console.error(`[ClickFit] Le bouton ${eye} est DÉSACTIVÉ!`);
alert(`Le bouton d'import ${eye.toUpperCase()} est désactivé`);
return;
}
uploadButton.click();
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] ÉTAPE 1/5: Ouverture du modal d\'import');
console.log('[ClickFit] ══════════════════════════════════════');
// ÉTAPE 1: Attendre et vérifier que le modal est bien ouvert
let modalReady = false;
let modalAttempts = 0;
const maxModalAttempts = 10;
while (!modalReady && modalAttempts < maxModalAttempts) {
modalAttempts++;
const modal = document.querySelector('.modal--size-medium, app-import-topography-modal, .modal-body');
const topographerSelectTest = document.querySelector('#input-topographer');
if (modal && topographerSelectTest) {
modalReady = true;
console.log(`[ClickFit] ✓ ÉTAPE 1 RÉUSSIE: Modal ouvert (tentative ${modalAttempts})`);
} else {
console.log(`[ClickFit] Attente modal... (tentative ${modalAttempts}/${maxModalAttempts})`);
await wait(300);
}
}
if (!modalReady) {
console.error('[ClickFit] ✗ ÉCHEC ÉTAPE 1: Modal non ouvert après ' + maxModalAttempts + ' tentatives');
return;
}
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] ÉTAPE 2/5: Sélection du topographe');
console.log('[ClickFit] ══════════════════════════════════════');
// ÉTAPE 2: Sélection du topographe
const topographerSelect = document.querySelector('#input-topographer');
if (topographerSelect) {
const topographerMapping = {
'tms4': 'tms_4',
'tms5': 'tms_5',
'medmont': 'medmont_6',
'pentacam': 'oculus_pentacam',
'keratron': 'keratron_scout',
'atlas': 'atlas9000',
'phoenix': 'sirius_phoenix',
'ca200': 'ca200',
'opd_scan': 'opdscan',
'orbscan': 'orbscan',
'keratograph': 'oculus_keratograph',
'easygraph': 'oculus_easygraph'
};
// LOG: Afficher toutes les options disponibles pour debug
console.log('[ClickFit] Options topographe disponibles:');
const availableOptions = [];
topographerSelect.querySelectorAll('option').forEach(o => {
availableOptions.push({ value: o.value, text: o.textContent.trim() });
console.log(` - "${o.value}" -> "${o.textContent.trim()}"`);
});
const selectValue = topographerMapping[group.topographer];
console.log(`[ClickFit] Topographe interne: "${group.topographer}" -> mapping: "${selectValue}"`);
let valueFound = false;
if (selectValue) {
// Vérifier si la valeur existe dans les options
const optionExists = availableOptions.some(o => o.value === selectValue);
console.log(`[ClickFit] Valeur "${selectValue}" existe dans le select: ${optionExists}`);
if (optionExists) {
topographerSelect.value = selectValue;
valueFound = true;
} else {
// Chercher une correspondance partielle (case-insensitive)
const matchingOption = availableOptions.find(o =>
o.value.toLowerCase().includes(group.topographer.toLowerCase()) ||
o.text.toLowerCase().includes(group.topographer_name?.toLowerCase() || group.topographer.toLowerCase())
);
if (matchingOption) {
console.log(`[ClickFit] Correspondance partielle trouvée: "${matchingOption.value}"`);
topographerSelect.value = matchingOption.value;
valueFound = true;
}
}
}
if (!valueFound) {
// Recherche par nom du topographe
const options = topographerSelect.querySelectorAll('option');
for (const option of options) {
const optionText = option.textContent.trim().toLowerCase();
const groupName = (group.topographer_name || group.topographer).toLowerCase();
if (optionText.includes(groupName) || groupName.includes(optionText)) {
console.log(`[ClickFit] Match par nom trouvé: "${option.value}" (${option.textContent.trim()})`);
topographerSelect.value = option.value;
valueFound = true;
break;
}
}
}
if (valueFound) {
// Déclencher les événements pour Angular (ordre important!)
topographerSelect.dispatchEvent(new Event('focus', { bubbles: true }));
topographerSelect.dispatchEvent(new Event('input', { bubbles: true }));
topographerSelect.dispatchEvent(new Event('change', { bubbles: true }));
topographerSelect.dispatchEvent(new Event('blur', { bubbles: true }));
// Aussi essayer ngModelChange pour Angular
try {
const ngChangeEvent = new CustomEvent('ngModelChange', { detail: topographerSelect.value, bubbles: true });
topographerSelect.dispatchEvent(ngChangeEvent);
} catch (e) { /* ignore */ }
console.log(`[ClickFit] Topographe sélectionné: "${topographerSelect.value}"`);
// IMPORTANT: Attendre qu'Angular mette à jour le formulaire et les validateurs
// Le topographe détermine quels fichiers sont acceptés !
console.log('[ClickFit] Attente mise à jour Angular après sélection topographe...');
await new Promise(r => setTimeout(r, 1500));
// VÉRIFICATION: S'assurer que la valeur a bien été appliquée
const verifySelect = document.querySelector('#input-topographer');
if (verifySelect && verifySelect.value === topographerSelect.value) {
console.log(`[ClickFit] ✓ ÉTAPE 2 RÉUSSIE: Topographe "${verifySelect.value}" confirmé`);
} else {
console.error('[ClickFit] ✗ ATTENTION: La valeur du topographe a peut-être changé!');
}
} else {
console.error(`[ClickFit] ✗ ÉCHEC ÉTAPE 2: Aucune option trouvée pour "${group.topographer}" / "${group.topographer_name}"`);
return;
}
} else {
console.error('[ClickFit] ✗ ÉCHEC ÉTAPE 2: Select topographe non trouvé');
return;
}
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] ÉTAPE 3/5: Téléchargement des fichiers');
console.log('[ClickFit] ══════════════════════════════════════');
// ÉTAPE 3: Input file
const fileInput = document.querySelector('input[type="file"]');
if (!fileInput) {
console.error('[ClickFit] ✗ ÉCHEC ÉTAPE 3: Input file non trouvé');
return;
}
// Charger fichiers
const files = [];
for (const filepath of group.files) {
const filename = filepath.split('/').pop().split('\\').pop();
try {
const response = await fetch(`${this.apiUrl}/file/${filename}`);
if (!response.ok) {
console.error(`Fichier non trouvé: ${filename}`);
continue;
}
const blob = await response.blob();
// PATCH XREF POUR AMILTON : Filtrer pour ne garder que la ligne de l'œil uploadé
// Problème : Amilton lit toujours la première ligne du XREF, donc OD et OG ont les mêmes valeurs
// Solution : Créer un XREF filtré avec seulement la ligne correspondant à cet œil
if (filename.toUpperCase() === 'XREF.DAT' && group.topographer === 'tms4' && group.xref_number) {
try {
const text = await blob.text();
const lines = text.split('\n');
// Garder les headers (lignes 0 et 1)
const filteredLines = [];
if (lines[0]) filteredLines.push(lines[0]);
if (lines[1]) filteredLines.push(lines[1]);
// Filtrer pour ne garder QUE la ligne correspondant à ce groupe
for (let i = 2; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const columns = line.split(',');
if (columns.length < 8) continue;
// Extraire l'ID du fichier (colonne 7)
const filenameCol = columns[7].trim();
let fileId = filenameCol;
if (filenameCol.includes('\\')) {
fileId = filenameCol.split('\\').pop();
}
fileId = fileId.replace(/\.TMS$/i, '').replace(/\.tms$/i, '');
// Ne garder QUE la ligne correspondant au xref_number de ce groupe
if (fileId === group.xref_number) {
filteredLines.push(line);
break; // Une seule ligne suffit
}
}
// Créer le nouveau XREF filtré
const filteredText = filteredLines.join('\n');
const filteredBlob = new Blob([filteredText], { type: 'text/plain' });
const file = new File([filteredBlob], filename, {
type: 'text/plain',
lastModified: Date.now()
});
files.push(file);
continue; // Skip le push normal ci-dessous
} catch (xrefError) {
console.error('Erreur filtrage XREF, utilisation du fichier original:', xrefError);
// En cas d'erreur, on continue avec le fichier original
}
}
// Fichier normal (ou XREF en cas d'erreur)
const file = new File([blob], filename, {
type: blob.type || 'application/octet-stream',
lastModified: Date.now()
});
files.push(file);
} catch (error) {
console.error(`Erreur téléchargement ${filename}:`, error);
}
}
if (files.length === 0) {
console.error('[ClickFit] ✗ ÉCHEC ÉTAPE 3: Aucun fichier téléchargé');
return;
}
// VÉRIFICATION ÉTAPE 3: Afficher les fichiers qui vont être uploadés
console.log(`[ClickFit] ✓ ÉTAPE 3 RÉUSSIE: ${files.length} fichier(s) téléchargé(s) pour ${group.topographer_name}:`);
files.forEach((f, i) => {
console.log(` ${i + 1}. "${f.name}" (${f.size} octets, type: ${f.type})`);
});
// Vérifier que tous les fichiers ont une taille > 0
const emptyFiles = files.filter(f => f.size === 0);
if (emptyFiles.length > 0) {
console.error(`[ClickFit] ✗ ATTENTION: ${emptyFiles.length} fichier(s) vide(s) détecté(s)!`);
emptyFiles.forEach(f => console.error(` - "${f.name}" est VIDE`));
}
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] ÉTAPE 4/5: Assignation des fichiers au formulaire');
console.log('[ClickFit] ══════════════════════════════════════');
// ÉTAPE 4: Assigner fichiers
const dt = new DataTransfer();
files.forEach(file => dt.items.add(file));
try {
fileInput.files = dt.files;
} catch(e) {
Object.defineProperty(fileInput, 'files', {
value: dt.files,
writable: false,
configurable: true
});
}
// Déclencher les événements pour Angular (ordre important)
fileInput.dispatchEvent(new Event('focus', { bubbles: true }));
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
fileInput.dispatchEvent(new Event('blur', { bubbles: true }));
// VÉRIFICATION ÉTAPE 4: S'assurer que les fichiers sont bien assignés
const assignedCount = fileInput.files ? fileInput.files.length : 0;
if (assignedCount === files.length) {
console.log(`[ClickFit] ✓ ÉTAPE 4 RÉUSSIE: ${assignedCount} fichier(s) assigné(s) au formulaire`);
} else {
console.error(`[ClickFit] ✗ ATTENTION ÉTAPE 4: Seulement ${assignedCount}/${files.length} fichiers assignés!`);
}
console.log('[ClickFit] Attente validation Angular...');
// IMPORTANT: Attendre que Angular traite les fichiers et valide
// C'est ici que le timing est critique !
await wait(2000);
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] ÉTAPE 5/5: Validation et clic sur Importer');
console.log('[ClickFit] ══════════════════════════════════════');
// ========================================
// CRANS DE SÉCURITÉ AVANT IMPORT
// ========================================
// Sécurité A: Vérifier que le topographe est toujours bien sélectionné
const topographerSelectCheck = document.querySelector('#input-topographer');
if (topographerSelectCheck) {
const selectedValue = topographerSelectCheck.value;
console.log(`[ClickFit] ✓ Sécurité A - Topographe toujours sélectionné: "${selectedValue}"`);
if (!selectedValue || selectedValue === '') {
console.error('[ClickFit] ✗ ÉCHEC Sécurité A: Le topographe a été désélectionné!');
return;
}
} else {
console.error('[ClickFit] ✗ ÉCHEC Sécurité A: Select topographe disparu!');
return;
}
// Sécurité B: Vérifier qu'il n'y a PAS de message d'erreur AVANT le clic
const preErrorSelectors = [
'.alert-danger',
'.error-message',
'.text-danger',
'.invalid-feedback',
'.mat-error'
];
let preErrorFound = false;
for (const selector of preErrorSelectors) {
const errorEls = document.querySelectorAll(selector);
errorEls.forEach(el => {
const text = el.textContent.trim();
// Filtrer les faux positifs (éléments vides ou très courts)
if (text && text.length > 10 && el.offsetParent !== null) {
console.error(`[ClickFit] ✗ Sécurité B - Erreur pré-import détectée: "${text}"`);
preErrorFound = true;
}
});
}
if (preErrorFound) {
console.error('[ClickFit] ✗ ÉCHEC Sécurité B: Erreur visible avant import - ABANDON');
return;
}
console.log('[ClickFit] ✓ Sécurité B - Aucune erreur visible dans le formulaire');
// Sécurité C: Vérifier que les fichiers sont bien assignés
const fileInputCheck = document.querySelector('input[type="file"]');
if (fileInputCheck && fileInputCheck.files) {
const assignedFilesCount = fileInputCheck.files.length;
if (assignedFilesCount > 0) {
console.log(`[ClickFit] ✓ Sécurité C - ${assignedFilesCount} fichier(s) toujours assigné(s)`);
} else {
console.error('[ClickFit] ✗ ÉCHEC Sécurité C: Les fichiers ne sont plus assignés!');
return;
}
}
// Sécurité D: Vérifier que les fichiers sont affichés dans l'UI
const modalContent = document.querySelector('.modal--size-medium, app-import-topography-modal, .modal-body');
if (modalContent) {
const modalText = modalContent.textContent || '';
let filesFoundInUI = 0;
for (const file of files) {
if (modalText.includes(file.name)) {
filesFoundInUI++;
}
}
if (filesFoundInUI > 0) {
console.log(`[ClickFit] ✓ Sécurité D - ${filesFoundInUI}/${files.length} fichier(s) visible(s) dans l'UI`);
} else {
console.warn('[ClickFit] ⚠ Sécurité D - Fichiers non visibles dans l\'UI (normal selon le topographe)');
// Attendre un peu plus au cas où
await wait(1000);
}
}
// Sécurité E: Attendre que le bouton Importer soit activé (validation terminée)
let importBtn = null;
let attempts = 0;
const maxAttempts = 20; // Augmenté pour plus de marge
while (!importBtn && attempts < maxAttempts) {
attempts++;
importBtn = Array.from(document.querySelectorAll('button')).find(btn =>
btn.textContent.includes('Importer') && !btn.disabled
);
if (!importBtn) {
console.log(`[ClickFit] Sécurité E - Attente bouton Importer actif... (tentative ${attempts}/${maxAttempts})`);
await wait(500);
}
}
if (!importBtn) {
// Le bouton est peut-être désactivé car les fichiers ne sont pas validés
const disabledBtn = Array.from(document.querySelectorAll('button')).find(btn =>
btn.textContent.includes('Importer')
);
if (disabledBtn && disabledBtn.disabled) {
console.error('[ClickFit] ✗ ÉCHEC Sécurité E: Bouton Importer DÉSACTIVÉ après ' + maxAttempts + ' tentatives');
console.error('[ClickFit] Les fichiers ne sont pas validés par Click&Fit pour ce topographe');
// Diagnostic: afficher l'état du formulaire
console.error('[ClickFit] === DIAGNOSTIC ===');
console.error(`[ClickFit] Topographe: ${topographerSelectCheck?.value}`);
console.error(`[ClickFit] Fichiers assignés: ${fileInputCheck?.files?.length}`);
// Vérifier s'il y a un message d'erreur visible
const errorEl = document.querySelector('.alert-danger, .error-message, .text-danger, .invalid-feedback');
if (errorEl && errorEl.textContent.trim()) {
console.error('[ClickFit] Message d\'erreur UI:', errorEl.textContent.trim());
}
return;
}
console.error('[ClickFit] ✗ ÉCHEC Sécurité E: Bouton Importer non trouvé dans le DOM');
return;
}
console.log('[ClickFit] ✓ Sécurité E - Bouton Importer trouvé et actif');
// Sécurité F: STABILISATION - Vérifier que le bouton reste actif pendant 500ms
// (évite de cliquer pendant une transition Angular)
console.log('[ClickFit] Sécurité F - Vérification stabilité du bouton (500ms)...');
await wait(500);
// Re-vérifier que le bouton est toujours actif
const importBtnStable = Array.from(document.querySelectorAll('button')).find(btn =>
btn.textContent.includes('Importer') && !btn.disabled
);
if (!importBtnStable) {
console.error('[ClickFit] ✗ ÉCHEC Sécurité F: Le bouton est devenu inactif pendant la stabilisation!');
return;
}
console.log('[ClickFit] ✓ Sécurité F - Bouton stable après 500ms');
// ========================================
// TOUS LES CRANS DE SÉCURITÉ PASSÉS - IMPORT
// ========================================
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] ✓ TOUTES LES SÉCURITÉS VALIDÉES');
console.log('[ClickFit] ══════════════════════════════════════');
console.log('[ClickFit] >>> CLIC SUR IMPORTER <<<');
importBtnStable.click();
// Attendre un peu pour voir si une erreur apparaît
await wait(DELAYS.SAVE_WAIT);
// Vérifier si une erreur est affichée dans le modal
const errorMessages = document.querySelectorAll('.alert-danger, .error-message, .text-danger, [class*="error"]');
let errorFound = false;
errorMessages.forEach(el => {
const text = el.textContent.trim();
if (text && text.length > 5) {
console.error(`[ClickFit] ERREUR détectée dans le modal: "${text}"`);
errorFound = true;
}
});
// Aussi chercher les messages toast/notification
const toasts = document.querySelectorAll('.toast, .notification, .snackbar, [class*="toast"]');
toasts.forEach(el => {
const text = el.textContent.trim();
if (text) {
console.log(`[ClickFit] Notification: "${text}"`);
}
});
if (!errorFound) {
// Marquer importé sur le serveur seulement si pas d'erreur
await this.markAsImported(group.files, group);
// Attendre que le modal se ferme (polling)
console.log('[ClickFit] Attente fermeture modal...');
await this.waitForModalToClose();
console.log('[ClickFit] ✓ Modal fermé, topographie importée avec succès!');
} else {
console.error('[ClickFit] ✗ Import échoué à cause d\'une erreur Click&Fit');
}
} catch (error) {
console.error('[ClickFit] ✗ Erreur import:', error);
}
},
/**
* Attend que le modal d'import soit fermé
* @param {number} timeout - Timeout en ms (défaut 15000)
* @returns {Promise}
*/
async waitForModalToClose(timeout = 15000) {
return new Promise((resolve) => {
const startTime = Date.now();
const checkModal = () => {
// Chercher les éléments du modal d'import
const modal = document.querySelector('.modal--size-medium');
const modalBackdrop = document.querySelector('.modal-backdrop');
const importModal = document.querySelector('app-import-topography-modal');
// Le modal est fermé si aucun de ces éléments n'existe ou n'est visible
const isModalOpen = (modal && modal.offsetParent !== null) ||
(modalBackdrop && modalBackdrop.offsetParent !== null) ||
(importModal && importModal.offsetParent !== null);
if (!isModalOpen) {
// Modal fermé, attendre un peu pour que l'UI se stabilise
setTimeout(resolve, 500);
return;
}
// Vérifier timeout
if (Date.now() - startTime > timeout) {
console.warn('[ClickFit] Timeout attente fermeture modal');
resolve();
return;
}
// Continuer à vérifier
setTimeout(checkModal, 200);
};
// Commencer après un délai initial (le temps que l'import démarre)
setTimeout(checkModal, 1000);
});
},
async loadFilesFromServer(group) {
const files = [];
for (const filepath of group.files) {
try {
// Extraire nom fichier
const filename = filepath.split('/').pop().split('\\').pop();
// Appeler API
const response = await fetch(`${this.apiUrl}/file/${encodeURIComponent(filename)}`);
if (!response.ok) {
console.error(` Erreur chargement ${filename}: ${response.status}`);
continue;
}
const blob = await response.blob();
// Créer File
const file = new File([blob], filename, {
type: this.getMimeType(filename)
});
files.push(file);
} catch (error) {
console.error(` Erreur chargement fichier:`, error);
}
}
return files;
},
getMimeType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const mimeTypes = {
'tgl': 'application/octet-stream',
'hgt': 'application/octet-stream',
'dst': 'application/octet-stream',
'csv': 'text/csv',
'txt': 'text/plain',
'xml': 'text/xml',
'dat': 'application/octet-stream'
};
return mimeTypes[ext] || 'application/octet-stream';
},
async waitForUploadModal(maxWait = 5000) {
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const modal = document.querySelector('.modal, app-modal, [role="dialog"]');
if (modal) {
await new Promise(r => setTimeout(r, 500));
return modal;
}
await wait(100);
}
throw new Error('Timeout: Modal non trouvée');
},
async findDropzone() {
// Sélecteurs
const selectors = [
'.dropzone.visible',
'.dropzone',
'app-files-dropzone .dropzone',
'[class*="drop"][class*="zone"]',
'.modal input[type="file"]'
];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element) {
return element;
}
}
// Input file parent
const fileInput = document.querySelector('input[type="file"]');
if (fileInput) {
return fileInput.parentElement;
}
return null;
},
async findValidateButton() {
// Sélecteurs bouton
const selectors = [
'.modal__footer button[type="submit"]',
'.modal__footer button.modal-submit-btn',
'.modal button:last-child',
'button[class*="submit"]',
'button[class*="import"]'
];
for (const selector of selectors) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
// Vérifier bouton
const text = button.textContent.toLowerCase();
if (text.includes('import') || text.includes('valid') || text.includes('ok')) {
return button;
}
}
}
// Recherche texte
const allButtons = document.querySelectorAll('.modal button, app-modal button');
for (const button of allButtons) {
const text = button.textContent.toLowerCase();
if ((text.includes('import') || text.includes('valid')) && !button.disabled) {
return button;
}
}
return null;
},
async waitForModalClose(maxWait = 5000) {
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const modal = document.querySelector('.modal, app-modal');
if (!modal) {
return;
}
await wait(100);
}
},
async markAsImported(files, groupInfo = null) {
try {
// Vérifier TMS-4
const keepXref = groupInfo && groupInfo.topographer === 'tms4' && groupInfo.multiple_eyes;
await fetch(`${this.apiUrl}/mark-imported`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
files: files,
move: true,
keep_xref: keepXref
})
});
} catch (error) {
console.error('Erreur marquage:', error);
}
},
async autoCreateWearerAndFile(group) {
let patientData = group?.parsedData || null;
try {
// Ouvrir le modal "Ajouter porteur"
// Essayer plusieurs sélecteurs pour le bouton "Ajouter porteur"
let addWearerBtn = null;
const wearerSelectors = [
'#wrapper > main > app-home > div > div:nth-child(1) > app-wearers-list > div.card__header.wearers-list > div > amds-button.wearers-add-btn.hydrated > button',
'amds-button.wearers-add-btn button',
'button[class*="wearers-add"]',
'button[class*="add-wearer"]'
];
for (const selector of wearerSelectors) {
addWearerBtn = document.querySelector(selector);
if (addWearerBtn) {
break;
}
}
// Si pas trouvé, chercher par texte
if (!addWearerBtn) {
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
const text = btn.textContent.trim();
if (text.includes('Ajouter') && text.includes('porteur')) {
addWearerBtn = btn;
break;
}
}
}
if (!addWearerBtn) {
console.error('Bouton "Ajouter porteur" non trouvé');
throw new Error('Bouton "Ajouter porteur" non trouvé');
}
try {
addWearerBtn.click();
} catch (error) {
console.error('Erreur lors du clic sur "Ajouter porteur":', error);
throw error;
}
// Attendre l'ouverture du modal
await wait(500);
// Remplir le nom
if (patientData?.lastName) {
const lastNameInput = document.querySelector('#input-lastName');
if (lastNameInput) {
lastNameInput.value = patientData.lastName;
lastNameInput.dispatchEvent(new Event('input', { bubbles: true }));
lastNameInput.dispatchEvent(new Event('change', { bubbles: true }));
lastNameInput.dispatchEvent(new Event('blur', { bubbles: true }));
}
}
// Remplir le prénom
if (patientData?.firstName) {
const firstNameInput = document.querySelector('#input-firstName');
if (firstNameInput) {
firstNameInput.value = patientData.firstName;
firstNameInput.dispatchEvent(new Event('input', { bubbles: true }));
firstNameInput.dispatchEvent(new Event('change', { bubbles: true }));
firstNameInput.dispatchEvent(new Event('blur', { bubbles: true }));
}
}
// Attendre clic utilisateur sur "Ajouter" puis auto-clic sur "Confirmer"
await new Promise((resolve) => {
const checkInterval = setInterval(() => {
// Chercher le bouton "Confirmer" avec plusieurs méthodes
let confirmBtn = null;
// Méthode 1: Sélecteur spécifique
let candidate = document.querySelector('#wrapper > app-modals-injector > div > div > app-modal > div.modal-container > div > div.modal__footer > div > div:nth-child(2) > amds-button.modal-submit-btn.ng-star-inserted.hydrated > button');
const isValidConfirm = (btn) => { if (!btn) return false; const t = (btn.textContent||'').trim().toLowerCase(); if (t.includes('ajouter') || t.includes('add')) return false; return ['confirmer','confirm','valider','ok'].some(v => t === v || t.includes(v)); };
if (candidate && isValidConfirm(candidate)) { confirmBtn = candidate; }
// Méthode 2: Sélecteur plus générique
if (!confirmBtn) {
candidate = document.querySelector('app-modal .modal__footer amds-button.modal-submit-btn button');
if (candidate && isValidConfirm(candidate)) { confirmBtn = candidate; } // Corrigé
}
// Méthode 3: Recherche par texte dans tous les boutons du modal
if (!confirmBtn) {
const modalButtons = document.querySelectorAll('app-modal button, .modal button, [role="dialog"] button');
for (const btn of modalButtons) {
const text = (btn.textContent || '').trim().toLowerCase();
if (text === 'confirmer' || text === 'confirm' || text === 'valider' || text === 'ok') {
confirmBtn = btn;
// Bouton trouvé
break;
}
}
}
// Si le bouton "Confirmer" existe et est actif, cliquer dessus
if (confirmBtn && !confirmBtn.disabled) {
// Auto-clic Confirmer
try {
confirmBtn.click();
// OK
// Attendre que le modal se ferme
setTimeout(() => {
clearInterval(checkInterval);
// next step
resolve();
}, 1500);
} catch (error) {
console.error('[ClickFit] Erreur clic Confirmer:', error);
clearInterval(checkInterval);
resolve();
}
return;
}
// Vérifier si le modal est fermé (annulation ou autre)
const modal = document.querySelector('app-modal, .modal, [role="dialog"]');
const modalVisible = modal && window.getComputedStyle(modal).display !== 'none';
if (!modalVisible) {
// Modal fermé
clearInterval(checkInterval);
resolve();
}
}, 300); // Vérification toutes les 300ms
// Timeout de sécurité (60 secondes)
setTimeout(() => {
// Timeout 60s
clearInterval(checkInterval);
resolve();
}, 60000);
});
// 4. Attendre que le porteur soit créé et que le bouton apparaisse
await wait(1000); // Augmenté à 1s pour les connexions lentes
// Fonction pour chercher le bouton avec plusieurs méthodes
async function findAddFileButton(maxAttempts = 5, delayBetween = 500) {
const selectors = [
'#wrapper > main > app-home > div > div:nth-child(2) > app-files-list > div.header-buttons.ng-star-inserted > amds-button.add-file-btn.ng-star-inserted.hydrated > button',
'amds-button.add-file-btn button',
'button[class*="add-file"]',
'button[class*="addFile"]',
'app-files-list button',
'div.header-buttons button'
];
// Textes possibles pour le bouton (français/anglais)
const buttonTexts = [
'ajouter', 'nouveau', 'créer', 'add', 'new', 'create'
];
const contextTexts = [
'dossier', 'fichier', 'file', 'folder', 'consultation'
];
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Tentative ${attempt}/${maxAttempts}
// Méthode 1: Sélecteurs CSS
for (const selector of selectors) {
const btn = document.querySelector(selector);
if (btn && !btn.disabled) {
// found
return btn;
}
}
// Méthode 2: Recherche par texte (plus flexible)
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
const text = (btn.textContent || '').toLowerCase().trim();
const hasAction = buttonTexts.some(t => text.includes(t));
const hasContext = contextTexts.some(t => text.includes(t));
if (hasAction && hasContext && !btn.disabled) {
// found by text
return btn;
}
}
if (attempt < maxAttempts) {
await wait(delayBetween);
}
}
return null;
}
const addFileBtn = await findAddFileButton(5, 800); // 5 tentatives, 800ms entre chaque
if (!addFileBtn) {
console.error('❌ Bouton "Ajouter fichier" non trouvé après 5 tentatives');
showToast('Bouton "Ajouter fichier" non trouvé - Vérifiez que le porteur a été créé');
throw new Error('Bouton "Ajouter fichier" non trouvé');
}
try {
addFileBtn.click();
} catch (error) {
console.error('Erreur lors du clic sur "Ajouter fichier":', error);
throw error;
}
// 5. Attendre le changement d'URL et déclencher l'import topographie
// FIX: La Promise se résout maintenant APRÈS la fin de l'import (plus de race condition)
const currentUrl = window.location.href;
await new Promise((resolve, reject) => {
let importStarted = false;
let timeoutId;
const urlCheckInterval = setInterval(async () => {
if (window.location.href !== currentUrl && window.location.href.includes('/file/')) {
clearInterval(urlCheckInterval);
// Protection contre les doublons
if (window.DesktopImportModule && window.DesktopImportModule.isImporting) {
console.log('[ClickFit] Import déjà en cours, skip');
resolve();
return;
}
importStarted = true;
window.DesktopImportModule.isImporting = true;
// Attendre que la page soit chargée
await wait(DELAYS.SAVE_WAIT);
try {
// NOUVEAU: Trouver tous les groupes sélectionnés dans cette session
// et les importer vers l'œil correspondant à leur sélection
// IMPORTANT: On prend TOUS les groupes sélectionnés, pas seulement ceux du même patient
// car l'utilisateur peut combiner des topos de sources différentes
// Collecter tous les groupes qui ont une sélection OD/OG et pas encore importés
const groupsToImport = this.currentGroups.filter(g => {
if (!g.selectedEye || g.selectedEye === 'skip') return false;
if (this.importedGroupIds.has(this.getGroupId(g))) return false;
if (g.alreadyImported) return false;
return true;
});
// Séparer les groupes par œil sélectionné
// On prend le PREMIER groupe sélectionné pour chaque œil
const odGroup = groupsToImport.find(g => g.selectedEye === 'od');
const ogGroup = groupsToImport.find(g => g.selectedEye === 'og');
console.log(`[ClickFit] Groupes à importer - OD: ${odGroup ? odGroup.topographer_name : 'AUCUN'}, OG: ${ogGroup ? ogGroup.topographer_name : 'AUCUN'}`);
// Importer OD d'abord (si sélectionné)
if (odGroup) {
const odGroupId = this.getGroupId(odGroup);
if (odGroupId) this.importedGroupIds.add(odGroupId);
odGroup.alreadyImported = true;
console.log(`[ClickFit] Import vers OD: ${odGroup.topographer_name}`);
await this.triggerDirectImport(odGroup, true);
// Pause entre les deux imports
if (ogGroup) await wait(DELAYS.SAVE_WAIT);
}
// Importer OG ensuite (si sélectionné)
if (ogGroup) {
const ogGroupId = this.getGroupId(ogGroup);
if (ogGroupId) this.importedGroupIds.add(ogGroupId);
ogGroup.alreadyImported = true;
console.log(`[ClickFit] Import vers OG: ${ogGroup.topographer_name}`);
await this.triggerDirectImport(ogGroup, true);
}
// Marquer aussi le groupe original s'il n'a pas été traité
const groupId = this.getGroupId(group);
if (groupId && !this.importedGroupIds.has(groupId)) {
this.importedGroupIds.add(groupId);
group.alreadyImported = true;
}
resolve();
} catch (error) {
console.error('[ClickFit] Erreur import:', error);
reject(error);
} finally {
window.DesktopImportModule.isImporting = false;
clearTimeout(timeoutId);
}
}
}, 100);
// Timeout de sécurité (60 secondes pour réseaux lents)
timeoutId = setTimeout(() => {
clearInterval(urlCheckInterval);
if (!importStarted) {
console.warn('[ClickFit] Timeout: la page /file/ n\'a pas été atteinte');
}
resolve(); // Résoudre quand même pour ne pas bloquer
}, 60000);
});
} catch (error) {
console.error('Erreur lors de l\'auto-création porteur + fichier:', error);
showToast('Erreur lors de la création automatique');
}
},
/**
* Trouve un groupe "frère" avec le même patient mais un œil différent
* @param {Object} group - Le groupe courant
* @returns {Object|null} - Le groupe frère ou null
*/
findSiblingEyeGroup(group) {
if (!this.currentGroups || !group.parsedData) return null;
const currentEye = group.selectedEye || 'od';
const targetEye = currentEye === 'od' ? 'og' : 'od';
const patientName = `${group.parsedData.lastName || ''}_${group.parsedData.firstName || ''}`.toLowerCase();
// Chercher un groupe avec le même patient et l'autre œil
for (const g of this.currentGroups) {
if (g === group) continue; // Ignorer le groupe courant
if (!g.parsedData) continue;
const gPatientName = `${g.parsedData.lastName || ''}_${g.parsedData.firstName || ''}`.toLowerCase();
// Même patient et œil différent sélectionné
if (gPatientName === patientName && g.selectedEye === targetEye) {
return g;
}
}
return null;
},
async triggerDirectImport(group, isSiblingImport = false) {
try {
// Déterminer l'œil à partir de selectedEye ou des données parsées
let targetEye = 'OD'; // Par défaut
// Utiliser selectedEye s'il est défini
if (group.selectedEye && group.selectedEye !== 'skip') {
targetEye = group.selectedEye.toUpperCase();
} else if (group.parsedData?.eye) {
// Sinon utiliser l'œil des données parsées
targetEye = group.parsedData.eye.toUpperCase();
}
console.log(`[ClickFit] Import topographie pour œil: ${targetEye}${isSiblingImport ? ' (sibling)' : ''}`);
// Utiliser directement performRealImport qui fonctionne bien
// Passer le flag isSiblingImport pour éviter le retour à la page d'accueil
await this.performRealImport(group, targetEye.toLowerCase(), isSiblingImport);
} catch (error) {
console.error('Erreur lors du déclenchement import direct:', error);
showToast('Erreur lors de l\'import direct');
}
}
};
// Global
window.DesktopImportModule = DesktopImportModule;
// Auto-init
setTimeout(() => {
if (window.DesktopImportModule) {
window.DesktopImportModule.init();
}
}, 2000);
// Dupliquer OD vers OG
function duplicateODtoOG() {
const mappings = [
{
from: '#input-rightsphere',
to: '#input-leftsphere',
name: 'Sphère'
},
{
from: '#input-rightcylinder',
to: '#input-leftcylinder',
name: 'Cylindre'
},
{
from: '#input-rightrefractionAxis',
to: '#input-leftrefractionAxis',
name: 'Axe'
}
];
let copiedCount = 0;
let errorMessages = [];
mappings.forEach(mapping => {
const fromInput = document.querySelector(mapping.from);
const toInput = document.querySelector(mapping.to);
if (fromInput && toInput) {
const value = fromInput.value;
if (value && value !== '') {
toInput.value = value;
toInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.dispatchEvent(new Event('change', { bubbles: true }));
if (mapping.name === 'Sphère' || mapping.name === 'Cylindre') {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
toInput.value = numValue.toFixed(2);
}
}
copiedCount++;
}
} else {
if (!fromInput) {
errorMessages.push(`Input source ${mapping.from} introuvable`);
}
if (!toInput) {
errorMessages.push(`Input destination ${mapping.to} introuvable`);
}
}
});
if (copiedCount > 0) {
showToast(` ${copiedCount} valeur(s) copiée(s) de OD vers OG`);
} else if (errorMessages.length > 0) {
showToast(' Erreur lors de la duplication');
console.error('Erreurs de duplication:', errorMessages);
} else {
showToast(' Aucune valeur à copier');
}
}
const selectors = [
"#wrapper > main > app-file-layout > div > app-file-tab-information > div.eyes-container > div > app-file-information-eye:nth-child(1) > div > div.header > div.header__actions > amds-button > button", // OD
"#wrapper > main > app-file-layout > div > app-file-tab-information > div.eyes-container > div > app-file-information-eye:nth-child(2) > div > div.header > div.header__actions > amds-button > button", // OG
"#wrapper > main > app-file-layout > div > app-file-tab-first-lens > div.lens-container > form > div > amds-button > button", // Prescripteur
"#wrapper > main > app-file-layout > div > app-file-tab-first-lens > div.lens-container > div > app-file-first-lens:nth-child(1) > div > div.header > div.header__actions > amds-button > button", // Lentille OD
"#wrapper > main > app-file-layout > div > app-file-tab-first-lens > div.lens-container > div > app-file-first-lens:nth-child(2) > div > div.header > div.header__actions > amds-button > button" // Lentille OG
];
const alreadyObserved = new WeakSet();
// Raccourci Double Alt
function setupDoubleAltShortcut() {
let lastAltTime = 0;
const doubleAltDelay = 500;
document.addEventListener('keydown', (e) => {
// Vérifier Alt
if (e.key === 'Alt') {
const currentTime = Date.now();
const timeDiff = currentTime - lastAltTime;
// Double Alt
if (timeDiff < doubleAltDelay) {
// Empêcher défaut
e.preventDefault();
e.stopPropagation();
// Enregistrer
// Boutons enregistrement
const saveButtons = document.querySelectorAll('amds-button button');
let savedCount = 0;
saveButtons.forEach((btn, index) => {
if (!btn.disabled && btn.textContent && btn.textContent.includes('Enregistr')) {
// Montrer bouton
const wasHidden = btn.style.display === 'none';
if (wasHidden) {
btn.style.display = '';
}
btn.click();
savedCount++;
// Re-cacher si nécessaire
if (wasHidden) {
setTimeout(() => {
btn.style.display = 'none';
}, 100);
}
}
});
// Attendre
setTimeout(() => {
// Bouton Notes
const notesButtons = document.querySelectorAll('button');
let notesButton = null;
notesButtons.forEach(btn => {
if (btn.textContent && btn.textContent.includes('Notes') &&
btn.closest('.header__actions')) {
notesButton = btn;
}
});
if (notesButton) {
notesButton.click();
} else {
showToast('Bouton Notes non trouvé');
}
}, 300);
} else {
// Premier Alt
lastAltTime = currentTime;
}
}
});
}
// Init raccourci
setupDoubleAltShortcut();
// Agrandir zone notes
function setupNotesEditAreaEnlargement() {
// Observer textarea avec ObserverManager
ObserverManager.createObserver(
'notesTextareaEnlargement',
(mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Chercher textarea
const textarea = node.querySelector ?
node.querySelector('textarea#input-editContent') : null;
// Vérifier nœud
const isEditTextarea = node.tagName === 'TEXTAREA' && node.id === 'input-editContent';
if (textarea || isEditTextarea) {
const targetTextarea = textarea || node;
// Appliquer styles
targetTextarea.style.minHeight = '200px';
targetTextarea.style.resize = 'vertical';
targetTextarea.style.fontSize = '14px';
targetTextarea.style.lineHeight = '1.4';
targetTextarea.style.padding = '12px';
targetTextarea.style.border = '2px solid #e0e0e0';
targetTextarea.style.borderRadius = '8px';
targetTextarea.style.transition = 'border-color 0.3s ease';
targetTextarea.style.overflow = 'hidden';
// Ajuster hauteur
function autoResizeTextarea(textarea) {
// Réinitialiser hauteur
textarea.style.height = 'auto';
// Calculer hauteur
const scrollHeight = textarea.scrollHeight;
const minHeight = 200;
const maxHeight = 500;
// Appliquer hauteur
const newHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
textarea.style.height = newHeight + 'px';
}
// Hauteur initiale
autoResizeTextarea(targetTextarea);
// Ajuster contenu
targetTextarea.addEventListener('input', () => {
autoResizeTextarea(targetTextarea);
});
// Ajuster focus
targetTextarea.addEventListener('focus', () => {
autoResizeTextarea(targetTextarea);
});
}
}
});
});
},
document.body,
{
childList: true,
subtree: true
},
true // persistent: les notes peuvent apparaître sur toutes les pages
);
}
// Init agrandissement
setupNotesEditAreaEnlargement();
// Centrage et déplacement modals
function setupModalCentering() {
// Centrer modal
function centerModal(modal) {
if (!modal || !modal.classList.contains('modal--size-medium')) return;
// Centrage CSS
modal.style.position = 'fixed';
modal.style.margin = '0';
modal.style.zIndex = '1050';
// Largeur
if (!modal.dataset.cfDefaultWidthSet) {
modal.style.width = '70vw';
modal.dataset.cfDefaultWidthSet = 'true';
}
// Centrer position
const rect = modal.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
modal.style.left = Math.max(0, (winW - rect.width) / 2) + 'px';
modal.style.top = Math.max(0, (winH - rect.height) / 2) + 'px';
modal.style.transform = 'none';
}
// Rendre déplaçable
function makeModalDraggable(modal) {
if (!modal || modal.dataset.cfDraggable === 'true') return;
const header = modal.querySelector('.modal__header');
if (!header) return;
modal.dataset.cfDraggable = 'true';
let isDragging = false;
let startX, startY, startLeft, startTop;
// Style header
header.style.cursor = 'move';
header.style.userSelect = 'none';
// Drag events
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cf-modal-grip')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
// Récupérer la position actuelle
const rect = modal.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
// S'assurer qu'il n'y a pas de transform
modal.style.transform = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
const newLeft = Math.max(0, Math.min(window.innerWidth - modal.offsetWidth, startLeft + deltaX));
const newTop = Math.max(0, Math.min(window.innerHeight - modal.offsetHeight, startTop + deltaY));
modal.style.left = newLeft + 'px';
modal.style.top = newTop + 'px';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
// Observer pour détecter l'ouverture de nouveaux modals avec ObserverManager
ObserverManager.createObserver(
'modalCenteringAndDragging',
(mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Chercher les modals dans le nouveau nœud
let modals = [];
if (node.querySelectorAll) {
modals = Array.from(node.querySelectorAll('.modal.modal--size-medium'));
}
// Ou vérifier si le nœud lui-même est un modal
if (node.classList && node.classList.contains('modal') &&
node.classList.contains('modal--size-medium')) {
modals.push(node);
}
modals.forEach(modal => {
if (!modal.dataset.cfCentered) {
modal.dataset.cfCentered = 'true';
// Attendre que le modal soit complètement rendu
setTimeout(() => {
centerModal(modal);
makeModalDraggable(modal);
}, 100);
}
});
}
});
});
},
document.body,
{
childList: true,
subtree: true
},
true // persistent: les modals peuvent apparaître partout
);
// Centrer et rendre déplaçables les modals existants
document.querySelectorAll('.modal.modal--size-medium').forEach(modal => {
if (!modal.dataset.cfCentered) {
modal.dataset.cfCentered = 'true';
centerModal(modal);
makeModalDraggable(modal);
}
});
// Re-centrer lors du redimensionnement de la fenêtre
window.addEventListener('resize', () => {
document.querySelectorAll('.modal.modal--size-medium').forEach(modal => {
centerModal(modal);
});
});
// Gestionnaire global unique pour fermer les modals avec Échap
let escapeHandlerAdded = false;
function addEscapeHandler() {
if (escapeHandlerAdded) return;
escapeHandlerAdded = true;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Trouver tous les modals visibles
const visibleModals = Array.from(document.querySelectorAll('.modal.modal--size-medium')).filter(modal => {
const rect = modal.getBoundingClientRect();
return modal.offsetParent !== null &&
modal.style.display !== 'none' &&
rect.width > 0 &&
rect.height > 0;
});
if (visibleModals.length > 0) {
const modal = visibleModals[visibleModals.length - 1]; // Le plus récent
// Essayer de fermer le modal
const closeButton = modal.querySelector('amds-button button[type="button"]');
const closeIcon = modal.querySelector('[name="ri-close-fill"], .ri-close-fill');
const overlay = modal.querySelector('.modal-overlay, .modal__overlay');
if (closeButton) {
closeButton.click();
} else if (closeIcon) {
closeIcon.click();
} else if (overlay) {
overlay.click();
} else {
modal.style.display = 'none';
}
e.preventDefault();
e.stopPropagation();
}
}
});
}
// Activer le gestionnaire
addEscapeHandler();
}
// Initialiser le centrage des modals
setupModalCentering();
// Observation des boutons
function observeAndAutoClick(selector, buttonLabel) {
const btn = document.querySelector(selector);
if (!btn || alreadyObserved.has(btn)) return;
btn.style.display = "none";
// Délai spécifique pour les boutons "Lentille OD" et "Lentille OG"
const needsDelay = buttonLabel.includes("Lentille");
const clickDelay = needsDelay ? 1500 : 0; // 1.5 secondes pour les lentilles
// Observer avec ObserverManager - nom unique par bouton
const observerName = `autoClickButton_${buttonLabel.replace(/\s+/g, '_')}`;
ObserverManager.createObserver(
observerName,
(mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
if (!btn.disabled && autoSaveEnabled) { // Vérifier si auto-save est activé
if (clickDelay > 0) {
// Vérifier si le canvas existe et attendre qu'il soit prêt
const checkCanvasAndClick = () => {
const canvas = document.querySelector('#webgl-canvas');
if (canvas) {
// Attendre un peu plus pour être sûr que le rendu est fait
setTimeout(() => {
btn.click();
}, clickDelay);
} else {
// Si pas de canvas, cliquer quand même après le délai
setTimeout(() => {
btn.click();
}, clickDelay);
}
};
checkCanvasAndClick();
} else {
btn.click();
}
}
}
});
},
btn,
{ attributes: true },
false // non-persistent: spécifique à un bouton qui peut disparaître
);
alreadyObserved.add(btn);
}
// Fonction pour scanner et observer tous les boutons
function scanAndObserveButtons() {
const labels = ["OD", "OG", "Prescripteur", "Lentille OD", "Lentille OG"];
selectors.forEach((sel, i) => {
const btn = document.querySelector(sel);
if (btn && !alreadyObserved.has(btn)) {
observeAndAutoClick(sel, labels[i]);
}
});
}
// Observation pour détecter l'apparition de nouveaux boutons
function setupButtonObserver() {
// Observer seulement les zones où les boutons peuvent apparaître
const targetZones = [
document.querySelector('#wrapper > main'),
document.querySelector('.eyes-container'),
document.querySelector('.lens-container')
].filter(el => el !== null);
// Target principal pour l'observer
const observerTarget = targetZones.length > 0 ? targetZones[0] : document.body;
// Observer spécifique pour les boutons avec ObserverManager
ObserverManager.createObserver(
'buttonAppearanceDetection',
(mutations) => {
let shouldScan = false;
mutations.forEach((mutation) => {
// Vérifier si des boutons ont pu être ajoutés
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) { // Element node
// Vérifier si c'est un bouton ou contient des boutons
if (node.tagName === 'BUTTON' ||
node.querySelector && node.querySelector('button')) {
shouldScan = true;
}
}
});
}
});
if (shouldScan) {
scanAndObserveButtons();
}
},
observerTarget,
{
childList: true,
subtree: true
},
false // non-persistent: observer spécifique à la page courante
);
// Observer les autres zones si elles existent
if (targetZones.length > 1) {
targetZones.slice(1).forEach((zone, index) => {
ObserverManager.createObserver(
`buttonAppearanceDetection_zone${index + 2}`,
(mutations) => {
let shouldScan = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && (node.tagName === 'BUTTON' ||
node.querySelector && node.querySelector('button'))) {
shouldScan = true;
}
});
}
});
if (shouldScan) {
scanAndObserveButtons();
}
},
zone,
{ childList: true, subtree: true },
false
);
});
}
}
function setupLensPageObserver() {
const targetZones = [
document.querySelector('#wrapper > main'),
document.querySelector('.lens-container'),
document.querySelector('[class*="lens"]')
].filter(el => el !== null);
// Déterminer la cible d'observation
let observerTarget;
if (targetZones.length > 0) {
observerTarget = targetZones[0];
} else if (document.querySelector('main')) {
observerTarget = document.querySelector('main');
} else {
return; // Pas de cible disponible
}
// Observer avec ObserverManager
ObserverManager.createObserver(
'lensPageRefractionDisplay',
(mutations) => {
let shouldCheckForLensPage = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
if (node.textContent &&
(node.textContent.includes('Œil droit') ||
node.textContent.includes('Œil gauche') ||
node.textContent.includes('Oeil droit') ||
node.textContent.includes('Oeil gauche'))) {
shouldCheckForLensPage = true;
}
if (node.querySelector &&
(node.querySelector('div.amds-text.amds-font-headline-h6') ||
node.querySelector('[class*="lens"]'))) {
shouldCheckForLensPage = true;
}
}
});
}
});
if (shouldCheckForLensPage) {
setTimeout(() => {
if (window.displayRefractionOnLensPage) {
window.displayRefractionOnLensPage();
}
}, 500);
}
},
observerTarget,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique à la page lentilles
);
// Observer les zones additionnelles si elles existent
if (targetZones.length > 1) {
targetZones.slice(1).forEach((zone, index) => {
ObserverManager.createObserver(
`lensPageRefractionDisplay_zone${index + 2}`,
(mutations) => {
let shouldCheckForLensPage = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
if (node.textContent &&
(node.textContent.includes('Œil droit') ||
node.textContent.includes('Œil gauche') ||
node.textContent.includes('Oeil droit') ||
node.textContent.includes('Oeil gauche'))) {
shouldCheckForLensPage = true;
}
if (node.querySelector &&
(node.querySelector('div.amds-text.amds-font-headline-h6') ||
node.querySelector('[class*="lens"]'))) {
shouldCheckForLensPage = true;
}
}
});
}
});
if (shouldCheckForLensPage) {
setTimeout(() => {
if (window.displayRefractionOnLensPage) {
window.displayRefractionOnLensPage();
}
}, 500);
}
},
zone,
{ childList: true, subtree: true },
false
);
});
}
}
// Force le step à 0.25 sur un input donné
function forceCustomStep(inputSelector, inputName) {
const input = document.querySelector(inputSelector);
if (!input || input.dataset.customStepApplied === 'true') return;
// Fonction helper pour vérifier si un input doit avoir le step 0.25
function shouldHaveCustomStep(inputElement) {
const stepInputs = [
'#input-rightsphere',
'#input-rightcylinder',
'#input-leftsphere',
'#input-leftcylinder',
'#input-rightaddition',
'#input-leftaddition'
];
return stepInputs.some(selector => {
return inputElement.matches(selector) || inputElement.id === selector.replace('#', '');
});
}
// Vérifier si cet input doit avoir le comportement personnalisé
if (!shouldHaveCustomStep(input)) {
return;
}
// Marquer comme traité
input.dataset.customStepApplied = 'true';
// Forcer les attributs
input.setAttribute('step', '0.25');
// Fonction pour arrondir au multiple de 0.25 le plus proche
function roundToStep(value, step = 0.25) {
return Math.round(value / step) * step;
}
// Fonction pour mettre à jour la valeur
function updateValue(newValue, keepFocus = false) {
const rounded = roundToStep(newValue, 0.25);
const formatted = rounded.toFixed(2);
// Sauvegarder l'état du focus et la position du curseur
const hasFocus = document.activeElement === input;
const selectionStart = input.selectionStart;
const selectionEnd = input.selectionEnd;
// Forcer la mise à jour de plusieurs façons
input.value = formatted;
input.setAttribute('value', formatted);
// Déclencher tous les événements possibles
['input', 'change'].forEach(eventType => {
input.dispatchEvent(new Event(eventType, { bubbles: true, cancelable: true }));
});
// Forcer si comp Angular utilsé
if (input._valueTracker) {
input._valueTracker.setValue(formatted);
}
// Restaurer le focus si nécessaire
if ((hasFocus || keepFocus) && document.activeElement !== input) {
input.focus();
// Restaurer la position du curseur
if (selectionStart !== null && selectionEnd !== null) {
input.setSelectionRange(selectionStart, selectionEnd);
}
}
}
// Intercepter et modifier le step natif
let isUpdating = false;
// Observer les changements de valeur avec ObserverManager
// Nom unique basé sur l'ID de l'input
const observerName = `inputValueStep_${input.id || inputSelector.replace(/[^a-zA-Z0-9]/g, '_')}`;
ObserverManager.createObserver(
observerName,
(mutations) => {
if (isUpdating) return;
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
const currentValue = parseFloat(input.value) || 0;
const roundedValue = roundToStep(currentValue, 0.25);
if (Math.abs(currentValue - roundedValue) > 0.001) {
isUpdating = true;
updateValue(roundedValue);
setTimeout(() => { isUpdating = false; }, 100);
}
}
});
},
input,
{
attributes: true,
attributeFilter: ['value']
},
false // non-persistent: spécifique à l'input de la page courante
);
// Intercepter les événements de changement
input.addEventListener('change', (e) => {
if (isUpdating) return;
const currentValue = parseFloat(e.target.value) || 0;
const roundedValue = roundToStep(currentValue, 0.25);
if (Math.abs(currentValue - roundedValue) > 0.001) {
e.preventDefault();
e.stopPropagation();
isUpdating = true;
updateValue(roundedValue);
setTimeout(() => { isUpdating = false; }, 100);
}
}, true);
// Gérer la molette
input.addEventListener('wheel', (e) => {
e.preventDefault();
e.stopPropagation();
const current = parseFloat(input.value) || 0;
const delta = e.deltaY < 0 ? 0.25 : -0.25;
updateValue(current + delta, true);
}, { passive: false, capture: true });
// Gérer les flèches du clavier
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
const current = parseFloat(input.value) || 0;
const delta = e.key === 'ArrowUp' ? 0.25 : -0.25;
updateValue(current + delta, true);
}
}, true);
// Intercepter le clic sur les boutons de step natifs (si présents)
const interceptStepButtons = () => {
const parent = input.parentElement;
if (!parent) return;
parent.addEventListener('click', (e) => {
// Si c'est un clic sur un bouton de step
if (e.target.matches('button, [role="button"]') && e.target !== input) {
e.preventDefault();
e.stopPropagation();
// Déterminer la direction
const rect = input.getBoundingClientRect();
const isUp = e.clientY < rect.top + rect.height / 2;
const current = parseFloat(input.value) || 0;
const delta = isUp ? 0.25 : -0.25;
updateValue(current + delta, true);
}
}, true);
setTimeout(() => {
// Chercher la div .arrows qui contient les boutons
const arrowsContainer = parent.querySelector('.arrows');
if (arrowsContainer) {
const buttons = arrowsContainer.querySelectorAll('button');
buttons.forEach((button, index) => {
// Remplacer complètement le comportement du bouton
const newButton = button.cloneNode(true);
button.parentNode.replaceChild(newButton, button);
newButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const current = parseFloat(input.value) || 0;
// Index 0 = bouton du haut (augmenter), Index 1 = bouton du bas (diminuer)
const delta = index === 0 ? 0.25 : -0.25;
updateValue(current + delta, true);
});
});
}
}, 100);
// Observer avec ObserverManager - nom unique par input
const arrowObserverName = `inputArrowButtons_${input.id || inputSelector.replace(/[^a-zA-Z0-9]/g, '_')}`;
ObserverManager.createObserver(
arrowObserverName,
(mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.classList && node.classList.contains('arrows')) {
const buttons = node.querySelectorAll('button');
buttons.forEach((button, index) => {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const current = parseFloat(input.value) || 0;
const delta = index === 0 ? 0.25 : -0.25;
updateValue(current + delta, true);
}, true);
});
}
});
});
},
parent,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique à l'input de la page courante
);
};
interceptStepButtons();
// Vérification périodiquement que le step est toujours à 0.25 avec ObserverManager
const stepCheckIntervalName = `inputStepCheck_${input.id || inputSelector.replace(/[^a-zA-Z0-9]/g, '_')}`;
ObserverManager.createInterval(
stepCheckIntervalName,
() => {
if (input.getAttribute('step') !== '0.25') {
input.setAttribute('step', '0.25');
}
},
1000,
false // non-persistent: spécifique à l'input de la page courante
);
}
// Liste des inputs qui doivent avoir le step 0.25
const stepInputs = [
'#input-rightsphere',
'#input-rightcylinder',
'#input-leftsphere',
'#input-leftcylinder',
'#input-rightaddition',
'#input-leftaddition'
];
// Appliquer le step custom à tous les inputs
function applyCustomStepToAll() {
const inputNames = {
'#input-rightsphere': 'Sphère OD',
'#input-rightcylinder': 'Cylindre OD',
'#input-leftsphere': 'Sphère OG',
'#input-leftcylinder': 'Cylindre OG'
};
stepInputs.forEach(selector => {
forceCustomStep(selector, inputNames[selector] || selector);
});
}
// Fonction pour réorganiser les champs de kératométrie
function reorderKeratometryFields() {
// Sélectionner les deux sections de kératométrie (OD et OG)
const eyeContainers = document.querySelectorAll('app-file-information-eye');
eyeContainers.forEach((eyeContainer, eyeIndex) => {
const eyeName = eyeIndex === 0 ? 'OD' : 'OG';
// Trouver la section kératométrie
const keratometrySection = eyeContainer.querySelector('app-file-information-eye-keratometry');
if (!keratometrySection) {
return;
}
// Trouver le conteneur des champs (.fields ou app-file-form-group selon la structure)
const fieldsContainer = keratometrySection.querySelector('.fields app-file-form-group') ||
keratometrySection.querySelector('app-file-form-group:last-of-type') ||
keratometrySection.querySelector('.form app-file-form-group:last-of-type');
if (!fieldsContainer) {
return;
}
// Identifier les champs par leur data-field ou leur ID
const fieldMapping = {
kFlat: fieldsContainer.querySelector('[data-field="kParameter"], [id*="kParameter"]:not([id*="steep"])')?.closest('app-store-field'),
excFlat: fieldsContainer.querySelector('[data-field="eccentricity"], [id*="eccentricity"]:not([id*="steep"])')?.closest('app-store-field'),
axisFlat: fieldsContainer.querySelector('[data-field="keratometryAxis"], [id*="keratometryAxis"]')?.closest('app-store-field'),
kSteep: fieldsContainer.querySelector('[data-field="steepKParameter"], [id*="steepKParameter"]')?.closest('app-store-field'),
excSteep: fieldsContainer.querySelector('[data-field="steepEccentricity"], [id*="steepEccentricity"]')?.closest('app-store-field'),
div30: fieldsContainer.querySelector('[data-field="visibleIrisDiameterAt30"], [id*="visibleIrisDiameterAt30"]')?.closest('app-store-field')
};
// Vérifier que tous les champs sont trouvés
const foundFields = Object.entries(fieldMapping).filter(([key, el]) => el !== null);
if (foundFields.length === 0) {
return;
}
// Ordre : Ligne 1: K plat, K serré, Axe | Ligne 2: Exc plat, Exc serré, DIV 30
const desiredOrder = ['kFlat', 'kSteep', 'axisFlat', 'excFlat', 'excSteep', 'div30'];
// Créer un fragment pour réorganiser
const fragment = document.createDocumentFragment();
// Ajout des champs dans le nouvel ordre
desiredOrder.forEach(fieldName => {
const field = fieldMapping[fieldName];
if (field) {
fragment.appendChild(field);
}
});
// Vider et remplir le conteneur
while (fieldsContainer.firstChild) {
fieldsContainer.removeChild(fieldsContainer.firstChild);
}
fieldsContainer.appendChild(fragment);
});
// Ajuster le style pour une meilleure présentation
injectKeratometryStyles();
}
// Styles pour améliorer la présentation
function injectKeratometryStyles() {
if (document.getElementById('keratometry-reorder-styles')) return;
const styles = `
<style id="keratometry-reorder-styles">
/* Améliorer l'espacement des champs de kératométrie */
app-file-information-eye-keratometry .fields app-file-form-group,
app-file-information-eye-keratometry .form > app-file-form-group:last-of-type {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 0.8rem !important;
}
/* LIGNE 1 : K plat, K serré, Axe */
app-file-information-eye-keratometry [data-field="kParameter"] {
grid-column: 1;
grid-row: 1;
}
app-file-information-eye-keratometry [data-field="steepKParameter"] {
grid-column: 2;
grid-row: 1;
}
app-file-information-eye-keratometry [data-field="keratometryAxis"] {
grid-column: 3;
grid-row: 1;
}
/* LIGNE 2 : Exc plat, Exc serré, DIV 30 */
app-file-information-eye-keratometry [data-field="eccentricity"] {
grid-column: 1;
grid-row: 2;
}
app-file-information-eye-keratometry [data-field="steepEccentricity"] {
grid-column: 2;
grid-row: 2;
}
app-file-information-eye-keratometry [data-field="visibleIrisDiameterAt30"] {
grid-column: 3;
grid-row: 2;
}
/* Couleurs douces pour différencier plat et serré */
/* Bleu doux pour les champs "plat" */
app-file-information-eye-keratometry [data-field="kParameter"] .amds-input,
app-file-information-eye-keratometry [data-field="eccentricity"] .amds-input {
background-color: rgba(59, 130, 246, 0.05) !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
}
app-file-information-eye-keratometry [data-field="kParameter"] .amds-input:hover,
app-file-information-eye-keratometry [data-field="eccentricity"] .amds-input:hover {
background-color: rgba(59, 130, 246, 0.08) !important;
border-color: rgba(59, 130, 246, 0.3) !important;
}
app-file-information-eye-keratometry [data-field="kParameter"] .amds-input:focus,
app-file-information-eye-keratometry [data-field="eccentricity"] .amds-input:focus {
background-color: rgba(59, 130, 246, 0.1) !important;
border-color: rgba(59, 130, 246, 0.4) !important;
}
/* Rouge doux pour les champs "serré" */
app-file-information-eye-keratometry [data-field="steepKParameter"] .amds-input,
app-file-information-eye-keratometry [data-field="steepEccentricity"] .amds-input {
background-color: rgba(239, 68, 68, 0.05) !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
}
app-file-information-eye-keratometry [data-field="steepKParameter"] .amds-input:hover,
app-file-information-eye-keratometry [data-field="steepEccentricity"] .amds-input:hover {
background-color: rgba(239, 68, 68, 0.08) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
}
app-file-information-eye-keratometry [data-field="steepKParameter"] .amds-input:focus,
app-file-information-eye-keratometry [data-field="steepEccentricity"] .amds-input:focus {
background-color: rgba(239, 68, 68, 0.1) !important;
border-color: rgba(239, 68, 68, 0.4) !important;
}
/* Style neutre pour l'axe */
app-file-information-eye-keratometry [data-field="keratometryAxis"] .amds-input {
background-color: rgba(107, 114, 128, 0.05) !important;
border: 1px solid rgba(107, 114, 128, 0.2) !important;
}
app-file-information-eye-keratometry [data-field="keratometryAxis"] .amds-input:hover {
background-color: rgba(107, 114, 128, 0.08) !important;
border-color: rgba(107, 114, 128, 0.3) !important;
}
app-file-information-eye-keratometry [data-field="keratometryAxis"] .amds-input:focus {
background-color: rgba(107, 114, 128, 0.1) !important;
border-color: rgba(107, 114, 128, 0.4) !important;
}
/* Style vert doux pour DIV 30 */
app-file-information-eye-keratometry [data-field="visibleIrisDiameterAt30"] .amds-input {
background-color: rgba(34, 197, 94, 0.05) !important;
border: 1px solid rgba(34, 197, 94, 0.2) !important;
}
app-file-information-eye-keratometry [data-field="visibleIrisDiameterAt30"] .amds-input:hover {
background-color: rgba(34, 197, 94, 0.08) !important;
border-color: rgba(34, 197, 94, 0.3) !important;
}
app-file-information-eye-keratometry [data-field="visibleIrisDiameterAt30"] .amds-input:focus {
background-color: rgba(34, 197, 94, 0.1) !important;
border-color: rgba(34, 197, 94, 0.4) !important;
}
/* Améliorer la lisibilité des labels */
app-file-information-eye-keratometry .amds-form-element label {
font-weight: 500;
}
/* Transition douce sur tous les inputs */
app-file-information-eye-keratometry .amds-input {
transition: all 0.2s ease;
}
/* S'assurer que les inputs ont la même hauteur */
app-file-information-eye-keratometry .amds-input input {
height: 38px;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
// Fonction de raccourcis clavier
function setupKeyboardShortcuts() {
document.addEventListener('keydown', async (event) => {
// F1 - Coller réfraction depuis le presse-papier
if (event.key === 'F2') {
event.preventDefault();
event.stopPropagation();
// Vérifier qu'on est sur une page avec les champs
const odSphere = document.querySelector('#input-rightsphere');
const ogSphere = document.querySelector('#input-leftsphere');
if (odSphere && ogSphere) {
duplicateODtoOG();
} else {
showToast('Champs de réfraction non disponibles');
}
}
// F3 - Calcul OrthoK
else if (event.key === 'F3') {
event.preventDefault();
event.stopPropagation();
await performOrthoKCalculation();
}
// F4 - Calcul LRPG
else if (event.key === 'F4') {
event.preventDefault();
event.stopPropagation();
await performLRPGCalculation();
}
}, true); // Capture phase pour intercepter avant tout autre handler
}
// Fonction pour capturer et mémoriser les données de réfraction/kératométrie
function captureRefractionData() {
const data = {
od: {
sphere: null,
cylinder: null,
axis: null,
addition: null,
kerato: {
kFlat: null,
kSteep: null,
axisFlat: null
}
},
og: {
sphere: null,
cylinder: null,
axis: null,
addition: null,
kerato: {
kFlat: null,
kSteep: null,
axisFlat: null
}
}
};
// Capturer les valeurs de réfraction OD
const odSphere = document.querySelector('#input-rightsphere');
const odCylinder = document.querySelector('#input-rightcylinder');
const odAxis = document.querySelector('#input-rightrefractionAxis');
if (odSphere && odSphere.value) data.od.sphere = odSphere.value;
if (odCylinder && odCylinder.value) data.od.cylinder = odCylinder.value;
if (odAxis && odAxis.value) data.od.axis = odAxis.value;
// Capturer l'addition OD
const odAddition = document.querySelector('#input-rightaddition');
if (odAddition && odAddition.value) data.od.addition = odAddition.value;
// Capturer les valeurs de kératométrie OD
const odKFlat = document.querySelector('#input-rightkParameter');
const odKSteep = document.querySelector('#input-rightsteepKParameter');
const odAxisFlat = document.querySelector('#input-rightkeratometryAxis');
if (odKFlat && odKFlat.value) data.od.kerato.kFlat = odKFlat.value;
if (odKSteep && odKSteep.value) data.od.kerato.kSteep = odKSteep.value;
if (odAxisFlat && odAxisFlat.value) data.od.kerato.axisFlat = odAxisFlat.value;
// Capturer les valeurs de réfraction OG
const ogSphere = document.querySelector('#input-leftsphere');
const ogCylinder = document.querySelector('#input-leftcylinder');
const ogAxis = document.querySelector('#input-leftrefractionAxis');
if (ogSphere && ogSphere.value) data.og.sphere = ogSphere.value;
if (ogCylinder && ogCylinder.value) data.og.cylinder = ogCylinder.value;
if (ogAxis && ogAxis.value) data.og.axis = ogAxis.value;
// Capturer l'addition OG
const ogAddition = document.querySelector('#input-leftaddition');
if (ogAddition && ogAddition.value) data.og.addition = ogAddition.value;
// Capturer les valeurs de kératométrie OG
const ogKFlat = document.querySelector('#input-leftkParameter');
const ogKSteep = document.querySelector('#input-leftsteepKParameter');
const ogAxisFlat = document.querySelector('#input-leftkeratometryAxis');
if (ogKFlat && ogKFlat.value) data.og.kerato.kFlat = ogKFlat.value;
if (ogKSteep && ogKSteep.value) data.og.kerato.kSteep = ogKSteep.value;
if (ogAxisFlat && ogAxisFlat.value) data.og.kerato.axisFlat = ogAxisFlat.value;
// Stocker dans sessionStorage
sessionStorage.setItem('clickfit_refraction_data', JSON.stringify(data));
return data;
}
// Injecte un mini bouton rond bleu avec flèche à côté de l'entête réfraction de OD, pour déclencher duplicateODtoOG()
function injectRefractionDuplicateButton() {
try {
const selector = 'app-file-information-eye:nth-child(1) app-file-information-eye-refraction app-accordion .header';
const headerEl = document.querySelector(selector);
if (!headerEl) {
return;
}
if (headerEl.querySelector('.cf-dup-refraction-btn')) return;
// Création outon rond bleu avec flèche blanche
const btn = document.createElement('button');
btn.className = 'cf-dup-refraction-btn';
btn.title = 'Dupliquer OD → OG (F2)';
btn.type = 'button';
btn.style.cssText = `
margin-left: 8px;
cursor: pointer;
background: #1e4b92;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
`;
// Flèche blanche vers la droite
btn.innerHTML = '<span style="color: white; font-size: 12px; font-weight: bold;">→</span>';
// Effet hover
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.boxShadow = '0 4px 8px rgba(0,0,0,0.2)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
});
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof duplicateODtoOG === 'function') {
duplicateODtoOG();
}
});
// Insérer à la fin de l'entête
headerEl.appendChild(btn);
// Observer les changements du DOM pour réinjecter si la section est rerendue
if (!window.__cfDupRefractionObserver) {
ObserverManager.createObserver(
'refractionDuplicateButtonReinject',
() => {
setTimeout(() => {
const missing = document.querySelector(selector);
if (missing && !missing.querySelector('.cf-dup-refraction-btn')) {
injectRefractionDuplicateButton();
}
}, 500);
},
document.body,
{ childList: true, subtree: true },
false // non-persistent: spécifique à la page courante
);
window.__cfDupRefractionObserver = true;
}
} catch (e) {
}
}
// Fonction pour afficher les données sur la page des lentilles avec encadré et légende
function displayRefractionOnLensPage() {
// Récupérer les données depuis sessionStorage
const storedData = sessionStorage.getItem('clickfit_refraction_data');
if (!storedData) {
return;
}
const data = JSON.parse(storedData);
// Chercher les divs contenant "Œil droit" et "Œil gauche"
const findEyeContainers = () => {
// Chercher par classe spécifique
const allDivs = document.querySelectorAll('div.amds-text.amds-font-headline-h6.amds-color-basic-900');
let odContainer = null;
let ogContainer = null;
for (let div of allDivs) {
// Nettoyage du texte
const text = div.textContent.replace(/\s+/g, ' ').trim();
if (text === 'Œil droit' || text === 'Oeil droit') {
odContainer = div;
} else if (text === 'Œil gauche' || text === 'Oeil gauche') {
ogContainer = div;
}
}
return { odContainer, ogContainer };
};
const { odContainer, ogContainer } = findEyeContainers();
// Fonction pour créer un encadré formaté avec légende (version horizontale)
const createInfoBox = (data, eye) => {
const eyeData = eye === 'od' ? data.od : data.og;
const isOD = eye === 'od';
// Couleurs différenciées OD (violet) / OG (bleu)
const colors = isOD ? {
background: '#faf8ff', // Violet très clair
border: '#d4c5e8', // Bordure violet doux
shadow: 'rgba(146, 75, 146, 0.08)', // Ombre violette douce
textPrimary: '#4a1e7c', // Texte violet foncé
textSecondary: '#6c4a8d', // Violet moyen pour les labels
separator: '#e4d5f0' // Séparateur violet clair
} : {
background: '#f8fbff', // Bleu clair (actuel pour OG)
border: '#d0d7e5', // Bordure bleue
shadow: 'rgba(30, 75, 146, 0.08)', // Ombre bleue
textPrimary: '#1a3d7c', // Texte bleu foncé
textSecondary: '#1e4b92', // Bleu moyen pour les labels
separator: '#d0d7e5' // Séparateur bleu clair
};
// Formater la réfraction
let refractionText = '';
if (eyeData.sphere || eyeData.cylinder) {
// Sphère
if (eyeData.sphere) {
const sphere = parseFloat(eyeData.sphere);
refractionText += sphere > 0 ? '+' : '';
refractionText += sphere.toFixed(2).replace('.', ',');
} else {
refractionText += 'Plan';
}
// Cylindre et axe
if (eyeData.cylinder && parseFloat(eyeData.cylinder) !== 0) {
const cylinder = parseFloat(eyeData.cylinder);
refractionText += ' (';
refractionText += cylinder > 0 ? '+' : '';
refractionText += cylinder.toFixed(2).replace('.', ',');
refractionText += ')';
if (eyeData.axis) {
refractionText += ' ' + parseInt(eyeData.axis) + '°';
}
}
} else {
refractionText = 'Non renseignée';
}
// Formater l'addition
let additionText = '';
if (eyeData.addition && parseFloat(eyeData.addition) !== 0) {
const addition = parseFloat(eyeData.addition);
additionText = 'Add +' + addition.toFixed(2).replace('.', ',');
}
// Formater la kératométrie
let keratoText = '';
if (eyeData.kerato.kFlat && eyeData.kerato.kSteep) {
keratoText = eyeData.kerato.kFlat.replace('.', ',') + ' × ' + eyeData.kerato.kSteep.replace('.', ',');
if (eyeData.kerato.axisFlat) {
keratoText += ' @ ' + eyeData.kerato.axisFlat + '°';
}
} else {
keratoText = 'Non renseignée';
}
// Créer l'élément encadré
const infoBox = document.createElement('div');
infoBox.className = `refraction-info refraction-info-${eye}`;
infoBox.style.cssText = `
border: 1px solid ${colors.border};
border-radius: 6px;
padding: 10px 14px;
margin: 8px 15px 8px 0;
background-color: ${colors.background};
box-shadow: 0 1px 3px ${colors.shadow};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
max-width: 700px;
`;
infoBox.innerHTML = `
<div style="
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
">
<span>📋</span>
<span>Données initiales</span>
</div>
<div style="
color: ${colors.textSecondary};
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
">
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-weight: 500;">Réfraction :</span>
<span style="font-weight: 600; color: ${colors.textPrimary};">${refractionText}</span>
</div>
${additionText ? `
<span style="color: ${colors.separator}; font-weight: 300;">|</span>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-weight: 600; color: ${colors.textPrimary};">${additionText}</span>
</div>
` : ''}
<span style="color: ${colors.separator}; font-weight: 300;">|</span>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-weight: 500;">Kératométrie :</span>
<span style="font-weight: 600; color: ${colors.textPrimary};">${keratoText}</span>
</div>
</div>
`;
return infoBox;
};
// Ajouter les informations pour OD
if (odContainer) {
// Chercher si l'info existe déjà dans le parent ou à côté
const parent = odContainer.parentElement;
let existingInfo = null;
// Chercher plus largement dans le parent du parent
const grandParent = parent ? parent.parentElement : null;
if (grandParent) {
existingInfo = grandParent.querySelector('.refraction-info-od');
}
if (!existingInfo) {
const odInfoBox = createInfoBox(data, 'od');
// Insérer après le parent du titre pour que ce soit bien en dessous
if (parent && parent.parentElement) {
parent.parentElement.insertBefore(odInfoBox, parent.nextSibling);
} else if (odContainer.nextSibling) {
odContainer.parentNode.insertBefore(odInfoBox, odContainer.nextSibling);
} else {
odContainer.appendChild(odInfoBox);
}
} else {
// Remplacer complètement l'info existante
const newInfoBox = createInfoBox(data, 'od');
existingInfo.replaceWith(newInfoBox);
}
} else {
}
// Ajouter les informations pour OG
if (ogContainer) {
// Chercher si l'info existe déjà dans le parent ou à côté
const parent = ogContainer.parentElement;
let existingInfo = null;
// Chercher plus largement dans le parent du parent
const grandParent = parent ? parent.parentElement : null;
if (grandParent) {
existingInfo = grandParent.querySelector('.refraction-info-og');
}
if (!existingInfo) {
const ogInfoBox = createInfoBox(data, 'og');
// Insérer après le parent du titre pour que ce soit bien en dessous
if (parent && parent.parentElement) {
parent.parentElement.insertBefore(ogInfoBox, parent.nextSibling);
} else if (ogContainer.nextSibling) {
ogContainer.parentNode.insertBefore(ogInfoBox, ogContainer.nextSibling);
} else {
ogContainer.appendChild(ogInfoBox);
}
} else {
// Remplacer complètement l'info existante
const newInfoBox = createInfoBox(data, 'og');
existingInfo.replaceWith(newInfoBox);
}
} else {
}
}
function setupRefractionPolling() {
window.captureRefractionData = captureRefractionData;
window.displayRefractionOnLensPage = displayRefractionOnLensPage;
// Reset à chaque changement de fiche patient (via l'URL) avec ObserverManager
let lastPatientId = null;
ObserverManager.createInterval(
'refractionDataPatientReset',
() => {
const match = location.pathname.match(/\/file\/([A-Za-z0-9]+)/);
const patientId = match ? match[1] : null;
if (patientId !== lastPatientId) {
sessionStorage.removeItem('clickfit_refraction_data');
lastPatientId = patientId;
}
},
500,
true // persistent: surveille les changements de patient globalement
);
// Polling toutes les 500ms sur la page info patient pour capture avec ObserverManager
ObserverManager.createInterval(
'refractionDataCapture',
() => {
// Teste la présence de TOUS les champs nécessaires (OD + OG)
const odSphere = document.querySelector('#input-rightsphere');
const odCyl = document.querySelector('#input-rightcylinder');
const odAxis = document.querySelector('#input-rightrefractionAxis');
const ogSphere = document.querySelector('#input-leftsphere');
const ogCyl = document.querySelector('#input-leftcylinder');
const ogAxis = document.querySelector('#input-leftrefractionAxis');
if (odSphere && odCyl && odAxis && ogSphere && ogCyl && ogAxis) {
const hasAnyValue = [odSphere.value, odCyl.value, odAxis.value, ogSphere.value, ogCyl.value, ogAxis.value]
.some(val => val && val.trim() !== '');
if (hasAnyValue) {
// Capture et sauvegarde si changement
const data = captureRefractionData();
const oldData = sessionStorage.getItem('clickfit_refraction_data');
if (JSON.stringify(data) !== oldData) {
sessionStorage.setItem('clickfit_refraction_data', JSON.stringify(data));
}
}
}
},
500,
true // persistent: capture les données sur toutes les pages patient
);
// Affichage automatique sur la page lentilles avec ObserverManager
ObserverManager.createInterval(
'refractionDataDisplayOnLensPage',
() => {
if (typeof window.displayRefractionOnLensPage === 'function') {
window.displayRefractionOnLensPage();
}
},
1500,
true // persistent: affichage sur toutes les pages lentilles
);
}
function fixLensPageAutoSaveAndTab() {
// Variables globales pour le module
let currentFocusedElement = null;
let shouldMaintainFocus = false;
let focusProtectionActive = false;
// Fonction pour détecter si on est sur la page lentilles
function isLensPage() {
const indicators = [
document.querySelector('.tab.lens-0-tab.active'),
document.querySelector('app-file-first-lens'),
document.querySelector('#input-righttype'),
document.querySelector('.lenses')
];
return indicators.some(el => el !== null);
}
if (!isLensPage()) {
return;
}
// Éviter la double activation
if (window.lensAutoSaveFixActive) {
return;
}
window.lensAutoSaveFixActive = true;
// ========================================
// PARTIE 1: NEUTRALISATION DE L'AUTO-SAVE
// ========================================
// Intercepter et bloquer les blur causés par l'auto-save
function protectFocus() {
if (focusProtectionActive) return;
focusProtectionActive = true;
// Sauvegarder les méthodes originales
const originalBlur = HTMLElement.prototype.blur;
const originalFocus = HTMLElement.prototype.focus;
// Override de blur pour empêcher le défocus non désiré
HTMLElement.prototype.blur = function() {
try {
// Si on est dans un input de la page lentilles et qu'on veut maintenir le focus
if (shouldMaintainFocus && this === currentFocusedElement) {
// Ne pas exécuter le blur
return;
}
// Sinon, exécuter normalement
return originalBlur.apply(this, arguments);
} catch (error) {
return originalBlur.apply(this, arguments);
}
};
// Intercepter les événements blur au niveau document
document.addEventListener('blur', function(e) {
try {
if (!shouldMaintainFocus) return;
const target = e.target;
// Vérifier si c'est un input de la page lentilles
if (target && target === currentFocusedElement &&
(target.tagName === 'INPUT' || target.tagName === 'SELECT')) {
// Vérifier si le nouveau focus est sur un autre élément interactif
setTimeout(() => {
const newFocus = document.activeElement;
// Si l'utilisateur a cliqué sur autre chose (bouton, lien, autre input), désactiver la protection
if (newFocus && newFocus !== target &&
(newFocus.tagName === 'BUTTON' ||
newFocus.tagName === 'A' ||
newFocus.tagName === 'INPUT' ||
newFocus.tagName === 'SELECT' ||
newFocus.closest('button') ||
newFocus.closest('[role="button"]'))) {
shouldMaintainFocus = false;
currentFocusedElement = null;
return;
}
// Sinon, restaurer le focus comme avant
if (currentFocusedElement && currentFocusedElement !== document.activeElement) {
// Restaurer le focus immédiatement
currentFocusedElement.focus();
// Pour les inputs numériques, resélectionner le contenu
if (currentFocusedElement.type === 'number' || currentFocusedElement.type === 'text') {
currentFocusedElement.select();
}
}
}, 50); // Petit délai pour laisser le nouveau focus se stabiliser
}
} catch (error) {
}
}, true); // Capture phase
// Détecter les clics sur des éléments non-interactifs pour désactiver la protection
document.addEventListener('click', function(e) {
try {
const target = e.target;
// Si l'utilisateur clique sur du texte, une div, ou autre élément non-interactif
if (shouldMaintainFocus && currentFocusedElement &&
(target.tagName === 'DIV' ||
target.tagName === 'SPAN' ||
target.tagName === 'P' ||
target.tagName === 'H1' ||
target.tagName === 'H2' ||
target.tagName === 'H3' ||
target.tagName === 'BODY' ||
target.tagName === 'MAIN' ||
target.closest('.content') ||
target.closest('.container'))) {
shouldMaintainFocus = false;
currentFocusedElement = null;
}
} catch (error) {
}
}, false);
// Exception pour les modals - désactiver la protection lors des clics sur boutons
document.addEventListener('click', function(e) {
try {
const target = e.target;
// Si c'est un bouton qui peut ouvrir un modal, désactiver temporairement la protection
if (target.tagName === 'BUTTON' ||
target.closest('button') ||
target.closest('[role="button"]') ||
target.textContent?.includes('Commencer') ||
target.textContent?.includes('commencer')) {
shouldMaintainFocus = false;
setTimeout(() => {
try {
} catch (error) {
}
}, 2000);
}
} catch (error) {
}
}, false);
// Observer les modals qui s'ouvrent pour désactiver complètement la protection avec ObserverManager
ObserverManager.createObserver(
'lensFocusProtectionModalDetection',
(mutations) => {
try {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Détecter l'ouverture d'un modal
if (node.classList?.contains('modal') ||
node.querySelector?.('.modal') ||
node.classList?.contains('overlay') ||
node.querySelector?.('.overlay')) {
shouldMaintainFocus = false;
focusProtectionActive = false;
// Ne pas réactiver automatiquement après fermeture du modal
// L'utilisateur doit cliquer manuellement pour réactiver la protection
}
}
});
});
} catch (error) {
}
},
document.body,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique au contexte de la page lentilles
);
}
// Tracker le focus actuel
function trackFocus() {
document.addEventListener('focusin', function(e) {
const target = e.target;
// Si c'est un input/select de la page lentilles
if (isLensPage() &&
(target.tagName === 'INPUT' || target.tagName === 'SELECT')) {
currentFocusedElement = target;
shouldMaintainFocus = true;
// Désactiver la protection après un délai (pour permettre la navigation normale)
clearTimeout(window.focusProtectionTimeout);
window.focusProtectionTimeout = setTimeout(() => {
shouldMaintainFocus = false;
}, 5000); // 5 secondes de protection
}
}, true);
// Réactiver la protection sur input
document.addEventListener('input', function(e) {
if (isLensPage() && e.target === currentFocusedElement) {
shouldMaintainFocus = true;
clearTimeout(window.focusProtectionTimeout);
window.focusProtectionTimeout = setTimeout(() => {
shouldMaintainFocus = false;
}, 2000);
}
}, true);
}
// ========================================
// PARTIE 2: AMÉLIORATION DE LA NAVIGATION TAB
// ========================================
function enhanceTabNavigation() {
document.addEventListener('keydown', function(e) {
if (e.key !== 'Tab' || !isLensPage()) return;
const activeElement = document.activeElement;
if (!activeElement || activeElement.tagName === 'BODY') return;
shouldMaintainFocus = false;
const allInputs = getAllNavigableElements();
const currentIndex = allInputs.indexOf(activeElement);
if (currentIndex === -1) return;
e.preventDefault();
e.stopPropagation();
// Calculer l'index suivant
let nextIndex;
if (e.shiftKey) {
nextIndex = currentIndex - 1;
if (nextIndex < 0) nextIndex = allInputs.length - 1;
} else {
nextIndex = currentIndex + 1;
if (nextIndex >= allInputs.length) nextIndex = 0;
}
// Naviguer vers le prochain élément
const nextElement = allInputs[nextIndex];
if (nextElement) {
// Désactiver temporairement la protection
shouldMaintainFocus = false;
setTimeout(() => {
nextElement.focus();
// Réactiver la protection sur le nouvel élément
currentFocusedElement = nextElement;
shouldMaintainFocus = true;
// Sélectionner le contenu pour les inputs
if (nextElement.tagName === 'INPUT' &&
(nextElement.type === 'text' || nextElement.type === 'number')) {
nextElement.select();
}
// Protection pendant 3 secondes
clearTimeout(window.focusProtectionTimeout);
window.focusProtectionTimeout = setTimeout(() => {
shouldMaintainFocus = false;
}, 3000);
}, 10);
}
}, true);
}
// Obtenir tous les éléments navigables dans l'ordre
function getAllNavigableElements() {
const allElements = [];
// IMPORTANT: D'abord TOUS les inputs OD, puis TOUS les inputs OG
// 1. Récupérer TOUS les inputs OD (pas les selects)
const odContainer = document.querySelector('app-file-first-lens:nth-child(1)');
if (odContainer) {
const odInputs = odContainer.querySelectorAll(`
input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]):not([disabled]):not([readonly])
`);
const visibleOdInputs = [];
odInputs.forEach(input => {
if (input.offsetParent !== null && input.tabIndex !== -1) {
visibleOdInputs.push(input);
}
});
// Trier par position verticale puis horizontale
visibleOdInputs.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
// D'abord par position verticale
if (Math.abs(rectA.top - rectB.top) > 10) {
return rectA.top - rectB.top;
}
// Puis par position horizontale
return rectA.left - rectB.left;
});
allElements.push(...visibleOdInputs);
// Log pour debug
visibleOdInputs.forEach((el, index) => {
const label = el.getAttribute('aria-label') || el.id || el.placeholder || 'inconnu';
});
}
// 2. Récupérer TOUS les inputs OG (pas les selects)
const ogContainer = document.querySelector('app-file-first-lens:nth-child(2)');
if (ogContainer) {
const ogInputs = ogContainer.querySelectorAll(`
input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]):not([disabled]):not([readonly])
`);
const visibleOgInputs = [];
ogInputs.forEach(input => {
if (input.offsetParent !== null && input.tabIndex !== -1) {
visibleOgInputs.push(input);
}
});
// Trier par position verticale puis horizontale
visibleOgInputs.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
// D'abord par position verticale
if (Math.abs(rectA.top - rectB.top) > 10) {
return rectA.top - rectB.top;
}
// Puis par position horizontale
return rectA.left - rectB.left;
});
allElements.push(...visibleOgInputs);
// Log pour debug
visibleOgInputs.forEach((el, index) => {
const label = el.getAttribute('aria-label') || el.id || el.placeholder || 'inconnu';
});
}
return allElements;
}
// ========================================
// PARTIE 3: INTERCEPTION DES SAVES ANGULAR
// ========================================
function interceptAngularSaves() {
// Intercepter les requêtes XHR pour détecter les saves
const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function() {
// Si c'est une requête de save sur la page lentilles
if (this.responseURL && this.responseURL.includes('/api/') && isLensPage()) {
// Sauvegarder l'élément actuellement focus
const elementToRestore = currentFocusedElement;
this.addEventListener('loadend', function() {
// Après la requête, restaurer le focus
if (elementToRestore && shouldMaintainFocus) {
setTimeout(() => {
if (elementToRestore !== document.activeElement) {
elementToRestore.focus();
if (elementToRestore.type === 'number' || elementToRestore.type === 'text') {
elementToRestore.select();
}
}
}, 100);
}
});
}
return originalXHRSend.apply(this, arguments);
};
}
// ========================================
// PARTIE 4: OBSERVER POUR RÉACTIVATION
// ========================================
function observePageChanges() {
ObserverManager.createObserver(
'lensPageActivationDetection',
() => {
// Vérifier si on a changé de page
if (!isLensPage() && window.lensAutoSaveFixActive) {
window.lensAutoSaveFixActive = false;
shouldMaintainFocus = false;
currentFocusedElement = null;
} else if (isLensPage() && !window.lensAutoSaveFixActive) {
fixLensPageAutoSaveAndTab();
}
},
document.body,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique au contexte lentilles
);
}
// ========================================
// INITIALISATION
// ========================================
protectFocus();
trackFocus();
enhanceTabNavigation();
interceptAngularSaves();
observePageChanges();
}
// Vérifier et activer toutes les secondes avec ObserverManager
ObserverManager.createInterval(
'lensTabActivationCheck',
() => {
const lensTab = document.querySelector('.tab.lens-0-tab.active');
if (lensTab && !window.lensAutoSaveFixActive) {
fixLensPageAutoSaveAndTab();
}
},
1000,
true // persistent: vérification globale pour activer le fix lentilles
);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(fixLensPageAutoSaveAndTab, 1000);
});
} else {
setTimeout(fixLensPageAutoSaveAndTab, 1000);
}
// Moduile video explicative
const VideoExplanationModule = {
// Configuration des vidéos par modèle
videos: {
'expert prog': {
url: 'https://www.youtube.com/embed/P9Kmm2fg6wk?si=YTkBsPOXq50fzJAI',
title: 'Guide Expert Prog'
}
// Ajout d'autres modèles/vidéos ici si besoin
},
// État du module
currentButtons: new Map(),
modalOpen: false,
// Initialisation
init() {
this.injectStyles();
this.setupObservers();
this.checkExistingSelects();
},
// Injection des styles CSS
injectStyles() {
if (document.getElementById('video-explanation-styles')) return;
const styles = `
/* Bouton vidéo */
.video-help-button {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 12px;
padding: 6px 12px;
background: linear-gradient(135deg, #1e4b92 0%, #245aa8 100%);
color: white;
border: none;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
vertical-align: middle;
}
.video-help-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
}
.video-help-button.pulse {
animation: pulse-video 2s infinite;
}
@keyframes pulse-video {
0% { box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); }
50% { box-shadow: 0 2px 16px rgba(102, 126, 234, 0.6); }
100% { box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); }
}
/* Modal vidéo */
.video-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
opacity: 0;
transition: opacity 0.3s ease;
backdrop-filter: blur(5px);
}
.video-modal-overlay.show {
opacity: 1;
}
.video-modal-container {
position: relative;
width: 90%;
max-width: 900px;
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
transform: scale(0.9);
transition: transform 0.3s ease;
}
.video-modal-overlay.show .video-modal-container {
transform: scale(1);
}
.video-modal-header {
background: linear-gradient(135deg, #1e4b92 0%, #245aa8 100%);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.video-modal-title {
color: white;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.video-modal-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s;
}
.video-modal-close:hover {
background: rgba(255, 255, 255, 0.3);
}
.video-modal-body {
position: relative;
padding-bottom: 56.25%; /* Ratio 16:9 */
height: 0;
background: #000;
}
.video-modal-body iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
`;
const styleEl = document.createElement('style');
styleEl.id = 'video-explanation-styles';
styleEl.textContent = styles;
document.head.appendChild(styleEl);
},
// Vérifier si un modèle nécessite une vidéo
needsVideo(modelText) {
if (!modelText) return null;
const lowerText = modelText.toLowerCase();
// Chercher dans la config des vidéos
for (const [key, videoData] of Object.entries(this.videos)) {
if (lowerText.includes(key)) {
return videoData;
}
}
return null;
},
// Créer le bouton vidéo
createVideoButton(videoData) {
const button = document.createElement('button');
button.className = 'video-help-button pulse';
button.innerHTML = `
<span>📹</span>
<span>Aide vidéo</span>
`;
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.openVideoModal(videoData);
button.classList.remove('pulse');
});
return button;
},
// Ouvrir le modal vidéo
openVideoModal(videoData) {
if (this.modalOpen) return;
this.modalOpen = true;
// Créer l'overlay et le modal
const overlay = document.createElement('div');
overlay.className = 'video-modal-overlay';
overlay.innerHTML = `
<div class="video-modal-container">
<div class="video-modal-header">
<h3 class="video-modal-title">${videoData.title}</h3>
<button class="video-modal-close">×</button>
</div>
<div class="video-modal-body">
<iframe
src="${videoData.url}"
title="${videoData.title}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen>
</iframe>
</div>
</div>
`;
document.body.appendChild(overlay);
// Animation d'ouverture
setTimeout(() => overlay.classList.add('show'), 10);
// Fermeture
const closeModal = () => {
overlay.classList.remove('show');
setTimeout(() => {
overlay.remove();
this.modalOpen = false;
}, 300);
};
// Event listeners pour fermer
overlay.querySelector('.video-modal-close').addEventListener('click', closeModal);
// Fermer en cliquant en dehors
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeModal();
}
});
// Fermer avec Echap
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
},
// Gérer l'ajout/suppression du bouton
handleSelectChange(selectElement) {
const selectId = selectElement.id;
const selectedOption = selectElement.options[selectElement.selectedIndex];
const modelText = selectedOption ? selectedOption.textContent : '';
// Retirer l'ancien bouton s'il existe
const existingButton = this.currentButtons.get(selectId);
if (existingButton) {
existingButton.remove();
this.currentButtons.delete(selectId);
}
// Vérifier si on doit ajouter un bouton
const videoData = this.needsVideo(modelText);
if (videoData) {
// Créer et ajouter le nouveau bouton
const button = this.createVideoButton(videoData);
// Trouver où insérer le bouton (après le select ou son conteneur)
const container = selectElement.closest('.amds-form-element') ||
selectElement.closest('.form-group') ||
selectElement.parentElement;
if (container) {
container.appendChild(button);
this.currentButtons.set(selectId, button);
// Retirer l'animation après 5 secondes
setTimeout(() => button.classList.remove('pulse'), 5000);
}
}
},
// Observer les changements sur les selects
setupObservers() {
// Observer les changements de valeur
const observeSelect = (selectId) => {
const select = document.querySelector(selectId);
if (select && !select.dataset.videoObserved) {
select.dataset.videoObserved = 'true';
// Écouter les changements
select.addEventListener('change', () => {
this.handleSelectChange(select);
});
// Vérification initiale
this.handleSelectChange(select);
}
};
// Observer les deux selects avec ObserverManager
ObserverManager.createInterval(
'lensModelSelectObserver',
() => {
observeSelect('#input-rightmodel');
observeSelect('#input-leftmodel');
},
1000,
false // non-persistent: spécifique à la page lentilles courante
);
},
// Vérifier les selects existants au chargement
checkExistingSelects() {
setTimeout(() => {
const rightSelect = document.querySelector('#input-rightmodel');
const leftSelect = document.querySelector('#input-leftmodel');
if (rightSelect) this.handleSelectChange(rightSelect);
if (leftSelect) this.handleSelectChange(leftSelect);
}, 2000);
}
};
// Barre d'espace pour navigation "Suivant"
function setupSpaceNext() {
document.addEventListener('keydown', (e) => {
// Si c'est la barre espace
if (e.key === ' ' || e.code === 'Space') {
// Si on est dans un input, on ne fait rien
if (document.activeElement.tagName === 'INPUT' ||
document.activeElement.tagName === 'TEXTAREA') {
return;
}
// Chercher le bouton suivant
const nextButton = document.querySelector('#input-nextstep > button');
if (nextButton) {
e.preventDefault(); // Empêcher le scroll
nextButton.click();
}
}
});
}
// Système de fix pour la navigation tab avec auto-save
function fixTabNavigationWithAutoSave() {
// Variables pour tracker le focus
let lastFocusedElement = null;
let lastFocusedSelector = null;
let isAutoSaving = false;
let tabQueue = [];
const fieldsOrder = [
// Réfraction OD
'#input-rightsphere',
'#input-rightcylinder',
'#input-rightrefractionAxis',
'#input-rightaddition',
// Réfraction OG
'#input-leftsphere',
'#input-leftcylinder',
'#input-leftrefractionAxis',
'#input-leftaddition',
// Kératométrie OD
'#input-rightkParameter',
'#input-rightsteepKParameter',
'#input-rightkeratometryAxis',
'#input-righteccentricity',
'#input-rightsteepEccentricity',
'#input-rightvisibleIrisDiameterAt30',
// Kératométrie OG
'#input-leftkParameter',
'#input-leftsteepKParameter',
'#input-leftkeratometryAxis',
'#input-lefteccentricity',
'#input-leftsteepEccentricity',
'#input-leftvisibleIrisDiameterAt30'
];
// Vérifier si on est sur la page information
function isOnInformationPage() {
return !!document.querySelector('#wrapper > main > app-file-layout > div > app-file-tab-information');
}
// Vérifier si on est sur la page first-lens
function isOnFirstLensPage() {
return !!document.querySelector('#wrapper > main > app-file-layout > div > app-file-tab-first-lens');
}
// Fonctions helper existantes
function getElementSelector(element) {
if (element.id) return `#${element.id}`;
let selector = element.tagName.toLowerCase();
if (element.className) {
selector += '.' + element.className.split(' ').join('.');
}
const siblings = element.parentElement?.querySelectorAll(selector);
if (siblings && siblings.length > 1) {
const index = Array.from(siblings).indexOf(element);
selector += `:nth-of-type(${index + 1})`;
}
return selector;
}
function saveCurrentFocus() {
const activeElement = document.activeElement;
if (activeElement && activeElement.tagName === 'INPUT') {
lastFocusedElement = activeElement;
lastFocusedSelector = getElementSelector(activeElement);
if (activeElement.selectionStart !== undefined) {
lastFocusedElement.dataset.lastCursorPos = activeElement.selectionStart;
}
}
}
function restoreFocus() {
if (lastFocusedSelector) {
const element = document.querySelector(lastFocusedSelector);
if (element && element !== document.activeElement) {
setTimeout(() => {
element.focus();
if (element.dataset.lastCursorPos) {
const pos = parseInt(element.dataset.lastCursorPos);
element.setSelectionRange(pos, pos);
delete element.dataset.lastCursorPos;
}
}, 50);
}
}
}
// Override de la navigation Tab
function overrideTabNavigation() {
document.addEventListener('keydown', (e) => {
// Vérifier qu'on est sur la page information
if (isOnFirstLensPage()) {
// Sur la page first-lens, ne PAS intercepter Tab
return;
}
if (!isOnInformationPage()) {
// Si on n'est pas sur la page information, ne rien faire
return;
}
// Ne traiter que Tab sans Shift (navigation avant)
if (e.key === 'Tab' && !e.shiftKey) {
const activeElement = document.activeElement;
// Vérifier si on est dans un champ de notre liste
const currentIndex = fieldsOrder.findIndex(selector =>
activeElement === document.querySelector(selector)
);
if (currentIndex !== -1) {
// On est dans un champ géré
e.preventDefault();
e.stopPropagation();
saveCurrentFocus();
// Trouver le prochain champ
let nextIndex = currentIndex + 1;
let nextElement = null;
// Chercher le prochain élément disponible
while (nextIndex < fieldsOrder.length && !nextElement) {
nextElement = document.querySelector(fieldsOrder[nextIndex]);
if (!nextElement || nextElement.disabled || nextElement.readOnly) {
nextElement = null;
nextIndex++;
}
}
// Si on est à la fin, boucler au début
if (!nextElement && nextIndex >= fieldsOrder.length) {
nextIndex = 0;
while (nextIndex < currentIndex && !nextElement) {
nextElement = document.querySelector(fieldsOrder[nextIndex]);
if (!nextElement || nextElement.disabled || nextElement.readOnly) {
nextElement = null;
nextIndex++;
}
}
}
if (nextElement) {
setTimeout(() => {
nextElement.focus();
nextElement.select();
}, isAutoSaving ? 100 : 0);
}
}
}
// Gérer aussi Shift+Tab (navigation arrière)
else if (e.key === 'Tab' && e.shiftKey) {
// Même vérification pour Shift+Tab
if (isOnFirstLensPage() || !isOnInformationPage()) {
return;
}
const activeElement = document.activeElement;
const currentIndex = fieldsOrder.findIndex(selector =>
activeElement === document.querySelector(selector)
);
if (currentIndex !== -1) {
e.preventDefault();
e.stopPropagation();
// Navigation arrière
let prevIndex = currentIndex - 1;
let prevElement = null;
while (prevIndex >= 0 && !prevElement) {
prevElement = document.querySelector(fieldsOrder[prevIndex]);
if (!prevElement || prevElement.disabled || prevElement.readOnly) {
prevElement = null;
prevIndex--;
}
}
if (!prevElement && prevIndex < 0) {
prevIndex = fieldsOrder.length - 1;
while (prevIndex > currentIndex && !prevElement) {
prevElement = document.querySelector(fieldsOrder[prevIndex]);
if (!prevElement || prevElement.disabled || prevElement.readOnly) {
prevElement = null;
prevIndex--;
}
}
}
if (prevElement) {
setTimeout(() => {
prevElement.focus();
prevElement.select();
}, isAutoSaving ? 100 : 0);
}
}
}
}, true);
}
// Détection de l'autoSave
function detectAutoSave() {
ObserverManager.createObserver(
'autoSaveDetectionForFocus',
(mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.target.tagName === 'BUTTON') {
const button = mutation.target;
if (button.textContent?.includes('Enregistrer') ||
button.className?.includes('save')) {
isAutoSaving = true;
saveCurrentFocus();
setTimeout(() => {
isAutoSaving = false;
restoreFocus();
}, 500);
}
}
});
},
document.body,
{
attributes: true,
subtree: true,
attributeFilter: ['disabled', 'class']
},
false // non-persistent: spécifique au contexte de la page
);
}
// Initialisation
detectAutoSave();
overrideTabNavigation();
}
/**
* Valide et enregistre le champ actuellement actif
* @param {Object} options - Options
* @param {boolean} options.simulateClick - Si true, simule un clic pour déclencher clickOutside Angular
* Utile pour les calculs LRPG/OrthoK, mais pas pour la navigation normale
*/
function commitActiveField(options = {}) {
const { simulateClick = false } = options;
const activeEl = document.activeElement;
if (!activeEl) return;
const tagName = activeEl.tagName;
const isInputLike = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
const isEditable = activeEl.isContentEditable;
if (!isInputLike && !isEditable) return;
try {
activeEl.dispatchEvent(new Event('input', { bubbles: true }));
activeEl.dispatchEvent(new Event('change', { bubbles: true }));
} catch (err) {
}
if (typeof activeEl.blur === 'function') {
activeEl.blur();
}
// Simuler un clic seulement si explicitement demandé (calculs LRPG/OrthoK)
// Ne PAS faire ça pendant la navigation normale car ça défocus l'utilisateur
if (simulateClick) {
setTimeout(() => {
try {
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
clientX: 0,
clientY: 0
});
document.dispatchEvent(clickEvent);
} catch (err) {
// Ignorer les erreurs silencieusement
}
}, 50);
}
}
async function waitForEnabledSaveButton(selector, { timeout = 2000, pollInterval = 100 } = {}) {
const start = performance.now();
while (performance.now() - start < timeout) {
const btn = document.querySelector(selector);
if (btn && !btn.disabled) {
return btn;
}
await wait(pollInterval);
}
const fallback = document.querySelector(selector);
return fallback && !fallback.disabled ? fallback : null;
}
// Fonction pour forcer l'enregistrement de tous les yeux
async function forceAutoSaveAllEyes(options = {}) {
const {
timeoutPerButton = 2000,
pollInterval = 100,
preserveFocus = false
} = options;
// Clic simulé pour déclencher validate() sur les input-dynamic avant sauvegarde
commitActiveField({ simulateClick: true });
// Sélecteurs des boutons d'enregistrement
const saveButtonSelectors = [
"#wrapper > main > app-file-layout > div > app-file-tab-information > div.eyes-container > div > app-file-information-eye:nth-child(1) > div > div.header > div.header__actions > amds-button > button",
"#wrapper > main > app-file-layout > div > app-file-tab-information > div.eyes-container > div > app-file-information-eye:nth-child(2) > div > div.header > div.header__actions > amds-button > button"
];
const activeElement = preserveFocus ? document.activeElement : null;
const selection =
preserveFocus &&
activeElement &&
typeof activeElement.selectionStart === 'number'
? {
start: activeElement.selectionStart,
end: activeElement.selectionEnd
}
: null;
let savedCount = 0;
for (const selector of saveButtonSelectors) {
const btn = await waitForEnabledSaveButton(selector, {
timeout: timeoutPerButton,
pollInterval
});
if (btn) {
const previousInlineDisplay = btn.style.display;
const computedDisplay = window.getComputedStyle(btn).display;
const shouldReveal = computedDisplay === 'none';
if (shouldReveal) {
btn.style.display = previousInlineDisplay && previousInlineDisplay !== 'none'
? previousInlineDisplay
: 'inline-flex';
}
btn.click();
if (shouldReveal) {
setTimeout(() => {
btn.style.display = previousInlineDisplay;
}, 50);
}
savedCount++;
await wait(400);
}
}
if (preserveFocus && activeElement && typeof activeElement.focus === 'function') {
setTimeout(() => {
if (!document.body.contains(activeElement)) return;
activeElement.focus();
if (selection && typeof activeElement.setSelectionRange === 'function') {
try {
activeElement.setSelectionRange(selection.start, selection.end);
} catch (err) {
}
}
}, 0);
}
if (savedCount > 0) {
await wait(600);
}
return savedCount;
}
// Fonction Ortho-K - Version optimisée avec détection intelligente
async function performOrthoKCalculation(button = null) {
// Feedback visuel si bouton fourni
if (button) {
button.classList.add('processing');
button.textContent = '⏳ Calcul...';
}
showToast('🔬 Démarrage du calcul OrthoK...');
try {
// Commit le champ actif avec clic simulé pour déclencher validate() sur les input-dynamic
commitActiveField({ simulateClick: true });
// Aller sur l'onglet lentille
const lensTab = document.querySelector('[class*="lens-0-tab"]');
if (!lensTab) {
showToast('❌ Onglet lentille introuvable');
console.error('Onglet lentille non trouvé');
return;
}
lensTab.click();
// Attendre que la page lentilles soit prête (détection intelligente)
const pageReady = await waitForLensPageReady();
if (!pageReady) {
showToast('❌ Page lentilles non chargée');
return;
}
// Attendre que le select OD soit prêt avec ses options
const rightTypeSelect = await waitForSelectReady('#input-righttype');
if (rightTypeSelect) {
rightTypeSelect.value = 'lens:type:orthok';
rightTypeSelect.dispatchEvent(new Event('change', { bubbles: true }));
rightTypeSelect.dispatchEvent(new Event('input', { bubbles: true }));
// Attendre le démarrage du calcul puis sa fin
await waitForCalculationStart();
await waitForCalculationComplete();
} else {
console.error('Select OD non trouvé');
}
// Attendre que le select OG soit prêt
const leftTypeSelect = await waitForSelectReady('#input-lefttype');
if (leftTypeSelect) {
leftTypeSelect.value = 'lens:type:orthok';
leftTypeSelect.dispatchEvent(new Event('change', { bubbles: true }));
leftTypeSelect.dispatchEvent(new Event('input', { bubbles: true }));
// Attendre le démarrage du calcul puis sa fin
await waitForCalculationStart();
await waitForCalculationComplete();
} else {
console.error('Select OG non trouvé');
}
showToast('✅ Calcul OrthoK terminé !');
// Désactiver temporairement la protection du focus après calcul
if (typeof shouldMaintainFocus !== 'undefined') {
shouldMaintainFocus = false;
setTimeout(() => {
if (typeof isLensPage === 'function' && isLensPage()) {
shouldMaintainFocus = true;
}
}, DELAYS.TOAST);
}
} catch (error) {
console.error('Erreur lors du calcul OrthoK:', error);
showToast('❌ Erreur lors du calcul OrthoK');
} finally {
// Restaurer le bouton
if (button) {
button.classList.remove('processing');
button.innerHTML = 'Ortho-K';
}
}
}
// Ajout styles split view
function injectSplitViewStyles() {
if (document.getElementById('clickfit-splitview-styles')) return;
const style = document.createElement('style');
style.id = 'clickfit-splitview-styles';
style.textContent = `
.clickfit-splitview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 100000;
background: #181a1b;
display: flex;
align-items: stretch;
justify-content: stretch;
transition: opacity 0.3s;
}
.clickfit-splitview-iframe {
flex: 1 1 0;
border: none;
width: 50vw;
height: 100vh;
min-width: 0;
min-height: 0;
background: white;
}
.clickfit-splitview-close {
position: absolute;
top: 14px;
right: 18px;
z-index: 100001;
background: rgba(255,255,255,0.8);
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 24px;
color: #181a1b;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
transition: background 0.2s;
opacity: 0.8;
}
.clickfit-splitview-close:hover {
background: #fff;
opacity: 1;
}
`;
document.head.appendChild(style);
}
// Cache le body sauf split view
let splitViewHiddenNodes = [];
function activateSplitView() {
if (document.querySelector('.clickfit-splitview-overlay')) return;
injectSplitViewStyles();
// Masquer tout le contenu du body sauf split view
splitViewHiddenNodes = [];
Array.from(document.body.children).forEach(node => {
if (
node.classList &&
node.classList.contains('clickfit-splitview-overlay')
) {
// ne rien faire
} else if (
node.tagName === 'SCRIPT' ||
node.tagName === 'STYLE' ||
node.id === 'clickfit-splitview-styles'
) {
// laisser styles/scripts
} else {
// masquer
splitViewHiddenNodes.push({
node: node,
prevDisplay: node.style.display
});
node.style.display = 'none';
}
});
// Créer le panneau split view
const overlay = document.createElement('div');
overlay.className = 'clickfit-splitview-overlay';
// Bouton fermer
const closeBtn = document.createElement('button');
closeBtn.className = 'clickfit-splitview-close';
closeBtn.innerHTML = '×';
closeBtn.title = 'Fermer Split View';
closeBtn.addEventListener('click', deactivateSplitView);
overlay.appendChild(closeBtn);
// 2 iframes côte à côte
const url = window.location.href;
const iframe1 = document.createElement('iframe');
iframe1.className = 'clickfit-splitview-iframe';
iframe1.src = url;
iframe1.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
const iframe2 = document.createElement('iframe');
iframe2.className = 'clickfit-splitview-iframe';
iframe2.src = url;
iframe2.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
overlay.appendChild(iframe1);
overlay.appendChild(iframe2);
document.body.appendChild(overlay);
}
// Desactiver le SplitView
function deactivateSplitView() {
// Supprimer split view
const overlay = document.querySelector('.clickfit-splitview-overlay');
if (overlay) overlay.remove();
// Restaurer le body
if (splitViewHiddenNodes && splitViewHiddenNodes.length > 0) {
splitViewHiddenNodes.forEach(({node, prevDisplay}) => {
node.style.display = prevDisplay || '';
});
splitViewHiddenNodes = [];
}
}
function autoCheckObservanceDefaults() {
// Garde-fou : vérifier qu'on est sur la bonne page
const observanceTitle = document.querySelector('h1, h2, h3, .title');
const isObservancePage = observanceTitle && observanceTitle.textContent.includes('Commun aux 2 yeux');
// Vérifier aussi la présence du composant observance
const observanceComponent = document.querySelector('app-observance');
if (!isObservancePage && !observanceComponent) {
return;
}
// Définir les options par défaut à cocher
const defaultSelections = [
{
name: 'Oxydant',
selector: 'app-store-field:nth-child(1) .input-radio-group > div:nth-child(1) input[type="radio"]'
},
{
name: 'Hebdomadaire',
selector: 'app-store-field:nth-child(2) .input-radio-group > div:nth-child(1) input[type="radio"]'
},
{
name: 'Aquadrop',
selector: 'app-store-field:nth-child(3) .input-radio-group > div:nth-child(1) input[type="radio"]'
},
{
name: 'Ventouse',
selector: 'app-store-field:nth-child(4) .input-radio-group > div:nth-child(2) input[type="radio"]'
}
];
// Pour chaque option par défaut
defaultSelections.forEach(selection => {
const radioButton = document.querySelector(`app-observance ${selection.selector}`);
if (radioButton && !radioButton.checked) {
// Cocher uniquement si pas déjà coché
radioButton.checked = true;
radioButton.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
// Observer pour détecter l'apparition de la page avec ObserverManager
ObserverManager.createObserver(
'observancePageDetection',
() => {
// Vérifier si on trouve le texte caractéristique
const hasObservanceText = Array.from(document.querySelectorAll('*')).some(el =>
el.textContent && el.textContent.includes('Commun aux 2 yeux') &&
el.textContent.includes('Produit utilisé')
);
if (hasObservanceText) {
// Attendre un peu que tous les éléments soient chargés
setTimeout(() => {
autoCheckObservanceDefaults();
}, 500);
}
},
document.body,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique à cette page
);
// Vérifier aussi immédiatement au cas où la page est déjà chargée
autoCheckObservanceDefaults();
//Sauvagarde dans la page Calcule lentilles
function interceptNextButton() {
// Observer pour détecter quand le bouton Suivant apparaît avec ObserverManager
ObserverManager.createObserver(
'lensNextButtonDetection',
() => {
const nextButton = document.querySelector('#wrapper > main > app-file-layout > div > app-tabs-list > div.tabs-container.has-actions > div.actions.ng-star-inserted > amds-button > button');
if (nextButton && !nextButton.dataset.intercepted) {
setupNextButtonInterceptor(nextButton);
}
},
document.body,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique à la page de calcul lentilles
);
// Vérifier aussi immédiatement
const nextButton = document.querySelector('#wrapper > main > app-file-layout > div > app-tabs-list > div.tabs-container.has-actions > div.actions.ng-star-inserted > amds-button > button');
if (nextButton) {
setupNextButtonInterceptor(nextButton);
}
}
function setupNextButtonInterceptor(nextButton) {
// Marquer comme déjà intercepté
nextButton.dataset.intercepted = 'true';
// Sauvegarder la fonction click originale
const originalClick = nextButton.onclick;
// Créer notre propre handler
const interceptedClick = async function(event) {
// Ne pas empêcher l'action par défaut - laisser le modal s'ouvrir
// Chercher et cliquer sur les boutons Enregistrer des lentilles
const saveButtons = await findAndClickLensSaveButtons();
if (saveButtons.length > 0) {
// Attendre un peu que les sauvegardes se fassent
await wait(1000);
// Vérifier que les boutons sont redevenus disabled (signe que c'est sauvegardé)
let allSaved = false;
let attempts = 0;
while (!allSaved && attempts < 10) {
allSaved = saveButtons.every(btn => btn.disabled);
if (!allSaved) {
await wait(500);
attempts++;
}
}
}
// Retirer temporairement notre intercepteur pour éviter la boucle
nextButton.removeEventListener('click', interceptedClick, true);
//nextButton.click();
setTimeout(() => {
nextButton.addEventListener('click', interceptedClick, true);
}, 100);
};
nextButton.addEventListener('click', interceptedClick, true);
nextButton.title = 'Enregistrera automatiquement les lentilles avant de continuer';
}
async function findAndClickLensSaveButtons() {
const saveButtons = [];
// Méthode 1 : Chercher par structure (page lentilles)
const lensContainers = document.querySelectorAll('.lens-container .header__actions amds-button');
lensContainers.forEach(amdsBtn => {
const btn = amdsBtn.querySelector('button');
if (btn && !btn.disabled) {
// Vérifier que c'est bien un bouton Enregistrer
const text = btn.textContent?.toLowerCase() || '';
const ariaLabel = btn.getAttribute('aria-label')?.toLowerCase() || '';
if (text.includes('enregistr') || ariaLabel.includes('save') || ariaLabel.includes('enregistr')) {
saveButtons.push(btn);
}
}
});
// Méthode 2 : Chercher par texte si méthode 1 ne trouve rien
if (saveButtons.length === 0) {
document.querySelectorAll('amds-button button').forEach(btn => {
if (!btn.disabled) {
const text = btn.textContent?.toLowerCase() || '';
if (text.includes('enregistr')) {
// Vérifier que c'est dans un contexte de lentille
const container = btn.closest('.lens-container, [class*="lens"]');
if (container) {
saveButtons.push(btn);
}
}
}
});
}
// Cliquer sur tous les boutons trouvés
saveButtons.forEach((btn, index) => {
// Montrer temporairement le bouton s'il est caché
const wasHidden = btn.style.display === 'none';
if (wasHidden) {
btn.style.display = '';
}
btn.click();
// Re-cacher si nécessaire
if (wasHidden) {
setTimeout(() => {
btn.style.display = 'none';
}, 100);
}
});
return saveButtons;
}
// Fonction pour réorganiser les sections
function reorderSectionsOnly() {
// Attendre que les sections soient chargées
const eyeContainers = document.querySelectorAll('app-file-information-eye');
if (eyeContainers.length < 2) {
return;
}
// Pour chaque œil
eyeContainers.forEach((eyeContainer, eyeIndex) => {
const eyeName = eyeIndex === 0 ? 'OD' : 'OG';
// Trouver le conteneur principal des sections
const contentContainer = eyeContainer.querySelector('.eye > .content');
if (!contentContainer) {
return;
}
// Identifier toutes les sections
const sections = {
refraction: contentContainer.querySelector('app-file-information-eye-refraction'),
keratometry: contentContainer.querySelector('app-file-information-eye-keratometry'),
visualAcuity: contentContainer.querySelector('app-file-information-eye-visual-acuity'),
biometry: contentContainer.querySelector('app-file-information-eye-biometry')
};
// Vérifier que toutes les sections sont présentes
const foundSections = Object.entries(sections).filter(([key, el]) => el !== null);
if (foundSections.length === 0) {
return;
}
// Créer un fragment pour réorganiser
const fragment = document.createDocumentFragment();
if (eyeIndex === 0) {
// OD : Réfraction, Kératométrie, Acuité visuelle, Biométrie
if (sections.refraction) fragment.appendChild(sections.refraction);
if (sections.keratometry) fragment.appendChild(sections.keratometry);
if (sections.visualAcuity) fragment.appendChild(sections.visualAcuity);
if (sections.biometry) fragment.appendChild(sections.biometry);
} else {
// OG : Kératométrie, Réfraction, Biométrie, Acuité visuelle
if (sections.keratometry) fragment.appendChild(sections.keratometry);
if (sections.refraction) fragment.appendChild(sections.refraction);
if (sections.biometry) fragment.appendChild(sections.biometry);
if (sections.visualAcuity) fragment.appendChild(sections.visualAcuity);
}
// Vider et remplir le conteneur
while (contentContainer.firstChild) {
contentContainer.removeChild(contentContainer.firstChild);
}
contentContainer.appendChild(fragment);
});
if (!document.getElementById('simple-grid-fix')) {
const style = document.createElement('style');
style.id = 'simple-grid-fix';
style.textContent = `
/* Fix simple pour la grille sans changer le design */
app-file-information-eye > .eye > .content {
display: grid !important;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 16px;
}
/* Position dans la grille pour OD */
app-file-information-eye:nth-child(1) app-file-information-eye-refraction {
grid-column: 1;
grid-row: 1;
order: 1;
}
app-file-information-eye:nth-child(1) app-file-information-eye-keratometry {
grid-column: 2;
grid-row: 1;
order: 2;
}
app-file-information-eye:nth-child(1) app-file-information-eye-visual-acuity {
grid-column: 1;
grid-row: 2;
order: 3;
}
app-file-information-eye:nth-child(1) app-file-information-eye-biometry {
grid-column: 2;
grid-row: 2;
order: 4;
}
/* Position dans la grille pour OG */
app-file-information-eye:nth-child(2) app-file-information-eye-keratometry {
grid-column: 1;
grid-row: 1;
order: 1;
}
app-file-information-eye:nth-child(2) app-file-information-eye-refraction {
grid-column: 2;
grid-row: 1;
order: 2;
}
app-file-information-eye:nth-child(2) app-file-information-eye-biometry {
grid-column: 1;
grid-row: 2;
order: 3;
}
app-file-information-eye:nth-child(2) app-file-information-eye-visual-acuity {
grid-column: 2;
grid-row: 2;
order: 4;
}
/* Responsive : quand la fenêtre est réduite */
@media (max-width: 1800px) {
app-file-information-eye > .eye > .content {
display: flex !important;
flex-direction: column !important;
gap: 16px;
}
/* Pour OD en mode mobile : Réfraction, Kératométrie, Biométrie, Acuité visuelle */
app-file-information-eye:nth-child(1) app-file-information-eye-refraction {
order: 1 !important;
}
app-file-information-eye:nth-child(1) app-file-information-eye-keratometry {
order: 2 !important;
}
app-file-information-eye:nth-child(1) app-file-information-eye-biometry {
order: 3 !important;
}
app-file-information-eye:nth-child(1) app-file-information-eye-visual-acuity {
order: 4 !important;
}
/* Pour OG en mode mobile : même ordre que OD pour la cohérence */
app-file-information-eye:nth-child(2) app-file-information-eye-refraction {
order: 1 !important;
}
app-file-information-eye:nth-child(2) app-file-information-eye-keratometry {
order: 2 !important;
}
app-file-information-eye:nth-child(2) app-file-information-eye-biometry {
order: 3 !important;
}
app-file-information-eye:nth-child(2) app-file-information-eye-visual-acuity {
order: 4 !important;
}
}
`;
document.head.appendChild(style);
}
}
// Fonction pour réorganiser les champs de kératométrie
function setupSimpleSectionReorderSafe() {
let currentPatientId = null;
function getCurrentPatientId() {
const match = location.pathname.match(/\/file\/([A-Za-z0-9]+)/);
return match ? match[1] : null;
}
// Fonction de réorganisation robuste
async function performReorganization() {
// Attendre que TOUTES les 8 sections soient présentes (4 par œil)
let attempts = 0;
const maxAttempts = 50; // 5 secondes max
while (attempts < maxAttempts) {
const sections = {
odRefraction: document.querySelector('app-file-information-eye:nth-child(1) app-file-information-eye-refraction'),
odKeratometry: document.querySelector('app-file-information-eye:nth-child(1) app-file-information-eye-keratometry'),
odVisualAcuity: document.querySelector('app-file-information-eye:nth-child(1) app-file-information-eye-visual-acuity'),
odBiometry: document.querySelector('app-file-information-eye:nth-child(1) app-file-information-eye-biometry'),
ogRefraction: document.querySelector('app-file-information-eye:nth-child(2) app-file-information-eye-refraction'),
ogKeratometry: document.querySelector('app-file-information-eye:nth-child(2) app-file-information-eye-keratometry'),
ogVisualAcuity: document.querySelector('app-file-information-eye:nth-child(2) app-file-information-eye-visual-acuity'),
ogBiometry: document.querySelector('app-file-information-eye:nth-child(2) app-file-information-eye-biometry')
};
const allSectionsLoaded = Object.values(sections).every(section => section !== null);
if (allSectionsLoaded) {
// Attendre un tout petit peu que Angular finisse le rendu
await wait(200);
// Réorganiser
reorderSectionsOnly();
// Puis réorganiser les champs de kératométrie
setTimeout(() => {
reorderKeratometryFields();
}, 100);
return true;
}
attempts++;
await new Promise(resolve => setTimeout(resolve, 100));
}
return false;
}
// Surveiller les changements d'URL (navigation SPA) avec ObserverManager
ObserverManager.createInterval(
'patientUrlChangeMonitoring',
async () => {
const newPatientId = getCurrentPatientId();
// Si on a changé de patient ou qu'on arrive sur un patient
if (newPatientId && newPatientId !== currentPatientId) {
currentPatientId = newPatientId;
// Lancer la réorganisation
await performReorganization();
}
},
250,
true // persistent: surveillance globale des changements de patient
);
// Observer pour les changements dynamiques (au cas où) avec ObserverManager
const container = document.querySelector('#wrapper');
if (container) {
ObserverManager.createObserver(
'patientFileReorganization',
() => {
// Si on est sur une fiche patient et qu'on n'a pas encore réorganisé
const patientId = getCurrentPatientId();
if (patientId && patientId === currentPatientId) {
// Ne rien faire, déjà traité
} else if (patientId) {
currentPatientId = patientId;
performReorganization();
}
},
container,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique au contexte de réorganisation
);
}
// Tentative initiale si déjà sur une fiche
if (getCurrentPatientId()) {
currentPatientId = getCurrentPatientId();
setTimeout(() => performReorganization(), 500);
}
}
function makeSpecificSectionsCollapsible() {
// CSS pour les sections rétractables
const styleId = 'simple-collapsible-patch';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.collapsible-header {
cursor: pointer !important;
user-select: none !important;
position: relative !important;
}
.section-chevron {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.3s ease;
font-size: 14px;
color: #666;
pointer-events: none;
}
.collapsed-section .section-chevron {
transform: translateY(-50%) rotate(-90deg);
}
.collapsed-section .accordion > .content {
display: none !important;
}
`;
document.head.appendChild(style);
}
function makeCollapsible(sectionElement, sectionName, eyeName) {
const accordion = sectionElement.querySelector('.accordion');
if (!accordion) {
return;
}
const header = accordion.querySelector('.header');
if (!header || header.dataset.collapsible === 'true') {
// log seulement si header absent
return;
}
header.dataset.collapsible = 'true';
header.classList.add('collapsible-header');
const oldChevron = header.querySelector('.section-chevron');
if (oldChevron) oldChevron.remove();
const chevron = document.createElement('span');
chevron.className = 'section-chevron';
chevron.innerHTML = '▼';
header.appendChild(chevron);
const isCollapsed = GM_getValue(`${sectionName}_${eyeName}_collapsed`, false);
if (isCollapsed) accordion.classList.add('collapsed-section');
else accordion.classList.remove('collapsed-section');
header.onclick = function(e) {
e.stopPropagation();
const wasCollapsed = accordion.classList.contains('collapsed-section');
if (wasCollapsed) {
accordion.classList.remove('collapsed-section');
} else {
accordion.classList.add('collapsed-section');
}
GM_setValue(`${sectionName}_${eyeName}_collapsed`, !wasCollapsed);
};
}
function applyToSpecificSections() {
// Vérifier qu'on est sur une page de dossier avec des informations d'yeux
if (!window.location.href.includes('/file/')) {
return; // Pas sur une page de dossier, pas besoin de sections rétractables
}
const eyeContainers = document.querySelectorAll('app-file-information-eye');
if (eyeContainers.length === 0) {
// Ne pas afficher d'erreur, juste retourner silencieusement
return;
}
eyeContainers.forEach((container, index) => {
const eyeName = index === 0 ? 'OD' : 'OG';
const biometry = container.querySelector('app-file-information-eye-biometry');
if (biometry) makeCollapsible(biometry, 'biometry', eyeName);
const visualAcuity = container.querySelector('app-file-information-eye-visual-acuity');
if (visualAcuity) makeCollapsible(visualAcuity, 'visual-acuity', eyeName);
});
}
// Appliquer maintenant
applyToSpecificSections();
// Observer les changements dynamiques avec ObserverManager
if (!window._cfCollapsibleObserver) {
ObserverManager.createObserver(
'collapsibleSectionsReapply',
() => {
// Seulement sur les pages de dossier
if (window.location.href.includes('/file/')) {
// Si aucune section rétractable n'est présente, réappliquer
if (document.querySelectorAll('.collapsible-header').length === 0) {
setTimeout(applyToSpecificSections, 300);
}
}
},
document.body,
{
childList: true,
subtree: true
},
false // non-persistent: spécifique au contexte des sections
);
window._cfCollapsibleObserver = true;
}
}
// Variable pour éviter les lancements multiples
let isLaunched = false;
// Lancement global
function launch() {
if (isLaunched) return;
isLaunched = true;
// Boutons de calcul LRPG et Ortho-K - Priorité haute avec retry
setTimeout(() => {
addCalculationButtonsToHeader();
// Retry après 3 secondes si les boutons ne sont pas encore là
setTimeout(() => {
if (!document.querySelector('.calc-buttons-container') && window.location.href.includes('/file/')) {
addCalculationButtonsToHeader();
}
}, 3000);
}, 1000);
// Module navigation patients
setTimeout(() => {
if (typeof SimplePatientNav !== 'undefined') {
SimplePatientNav.init();
}
}, 2000);
//Eléments rétractables
setTimeout(() => {
makeSpecificSectionsCollapsible();
// Réappliquer périodiquement pour les changements de page avec ObserverManager
ObserverManager.createInterval(
'collapsibleSectionsPeriodicReapply',
() => {
const needsReapply = document.querySelectorAll('.collapsible-header').length === 0;
if (needsReapply && window.location.pathname.includes('/file/')) {
makeSpecificSectionsCollapsible();
}
},
2000,
true // persistent: réapplication globale sur toutes les pages de dossier
);
}, 2000); // Attendre 2s au lieu de 500ms
// Module de réorganisation des sections
setTimeout(() => {
// setupSectionReorganizer(); // Fonction supprimée
}, 2000);
// Injecter les styles CSS
if (typeof injectStyles === 'function') {
injectStyles();
}
// Module Video
if (typeof VideoExplanationModule !== 'undefined') {
setTimeout(() => {
VideoExplanationModule.init();
}, 2000);
}
// Autres initialisations
if (typeof interceptNextButton === 'function') interceptNextButton();
if (typeof setupRefractionPolling === 'function') setupRefractionPolling();
if (typeof setupSpaceNext === 'function') setupSpaceNext();
if (typeof createFloatingButton === 'function') createFloatingButton();
if (typeof setupKeyboardShortcuts === 'function') setupKeyboardShortcuts();
if (typeof setupSimpleSectionReorderSafe === 'function') setupSimpleSectionReorderSafe();
if (typeof fixTabNavigationWithAutoSave === 'function') fixTabNavigationWithAutoSave();
if (typeof autoCheckObservanceDefaults === 'function') autoCheckObservanceDefaults();
if (typeof injectRefractionDuplicateButton === 'function') {
// Délai pour laisser le temps aux sections de se charger
setTimeout(() => {
injectRefractionDuplicateButton();
}, 2000);
// Retry après 5 secondes au cas où
setTimeout(() => {
injectRefractionDuplicateButton();
}, 5000);
}
if (typeof scanAndObserveButtons === 'function') {
scanAndObserveButtons();
ObserverManager.createInterval(
'scanAndObserveButtonsPeriodic',
() => {
scanAndObserveButtons();
},
2000,
true // persistent: scan périodique global
);
}
if (typeof setupButtonObserver === 'function') setupButtonObserver();
if (typeof setupLensPageObserver === 'function') setupLensPageObserver();
if (typeof applyCustomStepToAll === 'function') {
setTimeout(() => {
applyCustomStepToAll();
ObserverManager.createInterval(
'applyCustomStepPeriodic',
() => {
applyCustomStepToAll();
},
2000,
true // persistent: application périodique globale du custom step
);
}, 1000);
}
if (typeof patchColorAutoChange === 'function') patchColorAutoChange();
}
// Démarrage initial
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", launch);
} else {
delay(launch, DELAYS.LONG);
}
})();