Spaces:
Running
Running
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Application d'Inspection Qualité v2.1.0</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.23/jspdf.plugin.autotable.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: #f4f7f6; | |
| transition: margin-left 0.3s; | |
| } | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| z-index: 1000; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0,0,0,0.5); | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-content { | |
| background-color: #fff; | |
| margin: auto; | |
| padding: 2rem; | |
| border-radius: 0.5rem; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| width: 90%; | |
| max-width: 600px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| #toast-container { | |
| position: fixed; | |
| bottom: 1rem; | |
| right: 1rem; | |
| z-index: 1050; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .toast { | |
| padding: 0.75rem 1.25rem; | |
| border-radius: 0.375rem; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| opacity: 0; | |
| transition: opacity 0.5s ease-in-out, transform 0.3s ease-in-out; | |
| max-width: 300px; | |
| transform: translateX(100%); | |
| } | |
| .toast.show { | |
| opacity: 1; | |
| transform: translateX(0); | |
| } | |
| .toast-success { | |
| background-color: #d1fae5; | |
| color: #065f46; | |
| border-left: 4px solid #10b981; | |
| } | |
| .toast-error { | |
| background-color: #fee2e2; | |
| color: #991b1b; | |
| border-left: 4px solid #ef4444; | |
| } | |
| .toast-info { | |
| background-color: #dbeafe; | |
| color: #1e40af; | |
| border-left: 4px solid #3b82f6; | |
| } | |
| .toast-warning { | |
| background-color: #fef3c7; | |
| color: #92400e; | |
| border-left: 4px solid #f59e0b; | |
| } | |
| #sidebar { | |
| height: 100%; | |
| width: 0; | |
| position: fixed; | |
| z-index: 1001; | |
| top: 0; | |
| left: 0; | |
| background-color: #1e3a8a; | |
| overflow-x: hidden; | |
| transition: 0.3s; | |
| padding-top: 60px; | |
| box-shadow: 2px 0 5px rgba(0,0,0,0.2); | |
| } | |
| #sidebar a { | |
| padding: 10px 15px 10px 20px; | |
| text-decoration: none; | |
| font-size: 16px; | |
| color: #e0f2fe; | |
| display: block; | |
| white-space: nowrap; | |
| transition: 0.2s; | |
| border-left: 4px solid transparent; | |
| } | |
| #sidebar a:hover { | |
| background-color: #1d4ed8; | |
| color: white; | |
| } | |
| #sidebar a.active { | |
| background-color: #2563eb; | |
| color: white; | |
| font-weight: 600; | |
| border-left: 4px solid white; | |
| } | |
| #sidebar .close-btn { | |
| position: absolute; | |
| top: 10px; | |
| right: 15px; | |
| font-size: 24px; | |
| color: white; | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| } | |
| #sidebar .close-btn:hover { | |
| color: #bfdbfe; | |
| } | |
| .sidebar-divider { | |
| border-top: 1px solid rgba(255,255,255,0.2); | |
| margin: 15px 20px; | |
| } | |
| #main-content { | |
| transition: margin-left 0.3s; | |
| padding: 0; | |
| } | |
| #loading-indicator { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(255, 255, 255, 0.85); | |
| z-index: 2000; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| flex-direction: column; | |
| backdrop-filter: blur(2px); | |
| } | |
| #loading-indicator i { | |
| font-size: 3.5rem; | |
| color: #3b82f6; | |
| margin-bottom: 1.5rem; | |
| } | |
| #loading-indicator p { | |
| font-size: 1.1rem; | |
| color: #1e3a8a; | |
| font-weight: 500; | |
| } | |
| #menu-toggle { | |
| position: fixed; | |
| top: 1rem; | |
| left: 1rem; | |
| z-index: 1002; | |
| width: 40px; | |
| height: 40px; | |
| background-color: #1e3a8a; | |
| color: white; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.2); | |
| border: none; | |
| transition: background-color 0.2s, left 0.3s; | |
| } | |
| #menu-toggle:hover { | |
| background-color: #2563eb; | |
| } | |
| .welcome-card { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| } | |
| .feature-card { | |
| transition: transform 0.2s; | |
| } | |
| .feature-card:hover { | |
| transform: translateY(-2px); | |
| } | |
| .workflow-step { | |
| position: relative; | |
| } | |
| .workflow-step::after { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; | |
| right: -20px; | |
| width: 40px; | |
| height: 2px; | |
| background: #e5e7eb; | |
| } | |
| .workflow-step:last-child::after { | |
| display: none; | |
| } | |
| .kpi-card { | |
| background: white; | |
| border-radius: 0.5rem; | |
| padding: 1.5rem; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); | |
| border-left: 4px solid; | |
| } | |
| .kpi-card.success { | |
| border-left-color: #10b981; | |
| } | |
| .kpi-card.info { | |
| border-left-color: #3b82f6; | |
| } | |
| .kpi-card.warning { | |
| border-left-color: #f59e0b; | |
| } | |
| .kpi-card.danger { | |
| border-left-color: #ef4444; | |
| } | |
| .chart-container { | |
| background: white; | |
| border-radius: 0.5rem; | |
| padding: 1.5rem; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); | |
| height: 400px; /* CORRECTION: Hauteur fixe pour les graphiques */ | |
| position: relative; | |
| } | |
| .chart-container canvas { | |
| max-height: 300px ; /* CORRECTION: Hauteur max pour les canvas */ | |
| } | |
| .chart-container h3 { | |
| margin-bottom: 1rem; | |
| height: 2rem; /* Hauteur fixe pour le titre */ | |
| } | |
| /* NOUVEAU: Boutons de statut colorés */ | |
| .status-buttons { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .status-button { | |
| padding: 0.5rem 1rem; | |
| border: 2px solid transparent; | |
| border-radius: 0.375rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| min-width: 4rem; | |
| text-align: center; | |
| } | |
| .status-button.conforme { | |
| background-color: #f0fdf4; | |
| color: #15803d; | |
| border-color: #bbf7d0; | |
| } | |
| .status-button.conforme.active { | |
| background-color: #16a34a; | |
| color: white; | |
| border-color: #16a34a; | |
| } | |
| .status-button.non-conforme { | |
| background-color: #fef2f2; | |
| color: #dc2626; | |
| border-color: #fecaca; | |
| } | |
| .status-button.non-conforme.active { | |
| background-color: #dc2626; | |
| color: white; | |
| border-color: #dc2626; | |
| } | |
| .status-button.hors-plage { | |
| background-color: #fff7ed; | |
| color: #ea580c; | |
| border-color: #fed7aa; | |
| } | |
| .status-button.hors-plage.active { | |
| background-color: #ea580c; | |
| color: white; | |
| border-color: #ea580c; | |
| } | |
| .status-button.na { | |
| background-color: #f9fafb; | |
| color: #6b7280; | |
| border-color: #d1d5db; | |
| } | |
| .status-button.na.active { | |
| background-color: #6b7280; | |
| color: white; | |
| border-color: #6b7280; | |
| } | |
| .status-button:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| } | |
| @media (max-width: 768px) { | |
| #main-content { | |
| padding-left: 0 ; | |
| } | |
| #menu-toggle { | |
| left: 0.5rem; | |
| } | |
| .workflow-step::after { | |
| display: none; | |
| } | |
| .chart-container { | |
| height: 350px; /* Hauteur réduite sur mobile */ | |
| } | |
| .status-buttons { | |
| justify-content: center; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Menu latéral --> | |
| <div id="sidebar"> | |
| <button class="close-btn" onclick="closeSidebar()">×</button> | |
| <a href="#" id="sidebar-welcome" class="active" onclick="event.preventDefault(); UI.switchView('welcome')"> | |
| <i class="fas fa-home mr-2 w-5 text-center"></i> Accueil | |
| </a> | |
| <a href="#" id="sidebar-models" onclick="event.preventDefault(); UI.switchView('models')"> | |
| <i class="fas fa-clipboard-list mr-2 w-5 text-center"></i> Gestion Modèles | |
| </a> | |
| <a href="#" id="sidebar-inspections" onclick="event.preventDefault(); UI.switchView('inspections')"> | |
| <i class="fas fa-tasks mr-2 w-5 text-center"></i> Inspections | |
| </a> | |
| <a href="#" id="sidebar-dashboard" onclick="event.preventDefault(); UI.switchView('dashboard')"> | |
| <i class="fas fa-chart-line mr-2 w-5 text-center"></i> Tableau de Bord | |
| </a> | |
| <div class="sidebar-divider"></div> | |
| <a href="#" onclick="event.preventDefault(); UI.showHelpModal()"> | |
| <i class="fas fa-question-circle mr-2 w-5 text-center"></i> Aide | |
| </a> | |
| <div class="sidebar-divider"></div> | |
| <a href="#" onclick="event.preventDefault(); DataManager.confirmClearAllData()"> | |
| <i class="fas fa-trash-alt mr-2 w-5 text-center text-red-300"></i> <span class="text-red-300">Tout Effacer</span> | |
| </a> | |
| <div class="mt-auto text-white text-xs p-4 text-center absolute bottom-0 w-full"> | |
| <div>App Inspection Qualité</div> | |
| <div class="text-blue-200 mt-1">v2.1.0</div> | |
| </div> | |
| </div> | |
| <!-- Bouton menu --> | |
| <button id="menu-toggle" onclick="openSidebar()"> | |
| <i class="fas fa-bars"></i> | |
| </button> | |
| <!-- Contenu principal --> | |
| <div id="main-content"> | |
| <header class="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-4 shadow-lg sticky top-0 z-50"> | |
| <div class="flex justify-between items-center"> | |
| <div class="pl-12"> | |
| <h1 class="text-xl sm:text-2xl font-bold">Application d'Inspection Qualité</h1> | |
| <p class="text-xs sm:text-sm text-blue-100">Gestion checklists & inspections hors-ligne.</p> | |
| </div> | |
| </div> | |
| </header> | |
| <nav class="bg-white shadow-sm mb-6 sticky top-[72px] z-40"> | |
| <div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8"> | |
| <div class="flex justify-center h-16"> | |
| <div class="flex space-x-4 sm:space-x-8"> | |
| <button id="nav-welcome" class="nav-button border-b-2 border-blue-500 text-blue-600 inline-flex items-center px-1 pt-1 text-sm font-medium" onclick="UI.switchView('welcome')"> | |
| <i class="fas fa-home mr-1 sm:mr-2"></i> Accueil | |
| </button> | |
| <button id="nav-models" class="nav-button border-b-2 border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" onclick="UI.switchView('models')"> | |
| <i class="fas fa-clipboard-list mr-1 sm:mr-2"></i> Modèles | |
| </button> | |
| <button id="nav-inspections" class="nav-button border-b-2 border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" onclick="UI.switchView('inspections')"> | |
| <i class="fas fa-tasks mr-1 sm:mr-2"></i> Inspections | |
| </button> | |
| <button id="nav-dashboard" class="nav-button border-b-2 border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" onclick="UI.switchView('dashboard')"> | |
| <i class="fas fa-chart-line mr-1 sm:mr-2"></i> Tableau de Bord | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </nav> | |
| <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24"> | |
| <!-- Section Accueil --> | |
| <section id="welcome-section" class="space-y-8"> | |
| <div class="welcome-card rounded-lg p-8 text-center"> | |
| <h2 class="text-3xl font-bold mb-4">Bienvenue dans l'Application d'Inspection Qualité</h2> | |
| <p class="text-lg opacity-90 mb-6">Gérez vos inspections qualité de manière simple et efficace, même hors-ligne</p> | |
| <div class="flex flex-col sm:flex-row gap-4 justify-center"> | |
| <button onclick="UI.switchView('models')" class="bg-white text-blue-600 px-6 py-3 rounded-lg font-semibold hover:bg-blue-50 transition duration-200"> | |
| <i class="fas fa-rocket mr-2"></i>Commencer | |
| </button> | |
| <button onclick="UI.showHelpModal()" class="bg-blue-500 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-400 transition duration-200"> | |
| <i class="fas fa-question-circle mr-2"></i>Guide d'utilisation | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Workflow --> | |
| <div class="bg-white rounded-lg shadow-md p-6"> | |
| <h3 class="text-2xl font-semibold text-gray-800 mb-6 text-center">Comment ça fonctionne ?</h3> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> | |
| <div class="workflow-step text-center"> | |
| <div class="bg-blue-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"> | |
| <i class="fas fa-file-import text-2xl text-blue-600"></i> | |
| </div> | |
| <h4 class="text-lg font-semibold mb-2">1. Importez votre modèle</h4> | |
| <p class="text-gray-600">Importez un fichier Excel (.xlsx) contenant vos points de contrôle et paramètres d'inspection</p> | |
| </div> | |
| <div class="workflow-step text-center"> | |
| <div class="bg-green-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"> | |
| <i class="fas fa-clipboard-check text-2xl text-green-600"></i> | |
| </div> | |
| <h4 class="text-lg font-semibold mb-2">2. Réalisez l'inspection</h4> | |
| <p class="text-gray-600">Remplissez votre checklist point par point, ajoutez commentaires et photos selon les besoins</p> | |
| </div> | |
| <div class="workflow-step text-center"> | |
| <div class="bg-purple-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"> | |
| <i class="fas fa-chart-pie text-2xl text-purple-600"></i> | |
| </div> | |
| <h4 class="text-lg font-semibold mb-2">3. Analysez les résultats</h4> | |
| <p class="text-gray-600">Consultez les rapports détaillés et le tableau de bord pour analyser vos performances</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Features --> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | |
| <div class="feature-card bg-white rounded-lg shadow-md p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-wifi-off text-2xl text-blue-600 mr-3"></i> | |
| <h4 class="text-lg font-semibold">Fonctionnement hors-ligne</h4> | |
| </div> | |
| <p class="text-gray-600">Travaillez sans connexion Internet. Toutes vos données sont sauvegardées localement.</p> | |
| </div> | |
| <div class="feature-card bg-white rounded-lg shadow-md p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-camera text-2xl text-green-600 mr-3"></i> | |
| <h4 class="text-lg font-semibold">Photos et commentaires</h4> | |
| </div> | |
| <p class="text-gray-600">Ajoutez des photos et commentaires détaillés pour documenter vos observations.</p> | |
| </div> | |
| <div class="feature-card bg-white rounded-lg shadow-md p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-file-export text-2xl text-purple-600 mr-3"></i> | |
| <h4 class="text-lg font-semibold">Export et partage</h4> | |
| </div> | |
| <p class="text-gray-600">Exportez vos rapports en PDF et partagez vos inspections via des packages ZIP.</p> | |
| </div> | |
| <div class="feature-card bg-white rounded-lg shadow-md p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-cogs text-2xl text-orange-600 mr-3"></i> | |
| <h4 class="text-lg font-semibold">Types de paramètres variés</h4> | |
| </div> | |
| <p class="text-gray-600">Supporte texte, numérique, dates, listes déroulantes, cases à cocher et statuts.</p> | |
| </div> | |
| <div class="feature-card bg-white rounded-lg shadow-md p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-chart-line text-2xl text-red-600 mr-3"></i> | |
| <h4 class="text-lg font-semibold">Tableau de bord</h4> | |
| </div> | |
| <p class="text-gray-600">Visualisez vos performances avec des graphiques et indicateurs clés.</p> | |
| </div> | |
| <div class="feature-card bg-white rounded-lg shadow-md p-6"> | |
| <div class="flex items-center mb-4"> | |
| <i class="fas fa-shield-alt text-2xl text-teal-600 mr-3"></i> | |
| <h4 class="text-lg font-semibold">Sécurité alimentaire</h4> | |
| </div> | |
| <p class="text-gray-600">Conçu spécifiquement pour les audits et inspections en industrie alimentaire.</p> | |
| </div> | |
| </div> | |
| <!-- Tips --> | |
| <div class="bg-yellow-50 border-l-4 border-yellow-400 p-6 rounded-lg"> | |
| <h4 class="text-lg font-semibold text-yellow-800 mb-3"> | |
| <i class="fas fa-lightbulb mr-2"></i>Conseils pour bien commencer | |
| </h4> | |
| <ul class="text-yellow-700 space-y-2"> | |
| <li><i class="fas fa-check-circle mr-2 text-yellow-600"></i>Préparez votre fichier Excel avec les colonnes requises : ID_Point, Categorie, PointDeControle, TypeParametre</li> | |
| <li><i class="fas fa-check-circle mr-2 text-yellow-600"></i>Utilisez les nouveaux boutons colorés pour les statuts : Conforme (vert), Non Conforme (rouge), Hors Plage (orange), N.A. (gris)</li> | |
| <li><i class="fas fa-check-circle mr-2 text-yellow-600"></i>Exportez régulièrement vos inspections pour sauvegarder vos données</li> | |
| <li><i class="fas fa-check-circle mr-2 text-yellow-600"></i>Utilisez le tableau de bord pour suivre vos tendances et identifier les points d'amélioration</li> | |
| </ul> | |
| </div> | |
| </section> | |
| <!-- Section Modèles --> | |
| <section id="models-section" class="hidden space-y-6"> | |
| <h2 class="text-xl font-semibold text-gray-700">Gestion Modèles Checklists</h2> | |
| <div class="bg-white p-6 rounded-lg shadow border border-gray-200"> | |
| <h3 class="text-lg font-medium mb-4 text-gray-800">Importer Nouveau Modèle</h3> | |
| <!-- Bouton pour télécharger template --> | |
| <div class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg"> | |
| <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> | |
| <div> | |
| <h4 class="font-medium text-blue-800 mb-1">📥 Template Excel</h4> | |
| <p class="text-sm text-blue-600">Téléchargez le modèle Excel avec les colonnes requises</p> | |
| </div> | |
| <button id="download-template-button" class="px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center"> | |
| <i class="fas fa-download mr-2"></i>Télécharger Template | |
| </button> | |
| </div> | |
| </div> | |
| <div class="flex flex-col sm:flex-row items-start sm:items-center gap-3"> | |
| <input type="file" id="import-excel-input" accept=".xlsx" class="block w-full text-sm text-gray-600 border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"/> | |
| <button id="import-excel-button" class="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center" disabled> | |
| <i class="fas fa-file-import mr-2"></i>Importer Modèle | |
| </button> | |
| </div> | |
| <p class="mt-3 text-xs text-gray-500"> | |
| Fichier Excel (.xlsx). Requis: ID_Point, Categorie, PointDeControle, TypeParametre. | |
| Types supportés: Texte, Numérique, Date, Liste Déroulante, Case à cocher, Statut Seulement. | |
| </p> | |
| </div> | |
| <div class="bg-white p-6 rounded-lg shadow border border-gray-200"> | |
| <h3 class="text-lg font-medium mb-4 text-gray-800">Modèles Existants</h3> | |
| <div id="checklist-models-list" class="space-y-3"> | |
| <p class="text-gray-500 italic text-center py-4">Chargement...</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section Inspections --> | |
| <section id="inspections-section" class="hidden space-y-6"> | |
| <h2 class="text-xl font-semibold text-gray-700">Inspections</h2> | |
| <div class="bg-white p-6 rounded-lg shadow border border-gray-200"> | |
| <h3 class="text-lg font-medium mb-4 text-gray-800">Démarrer Nouvelle Inspection</h3> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="new-inspection-model" class="block text-sm font-medium text-gray-700 mb-1">Choisir modèle :</label> | |
| <select id="new-inspection-model" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md shadow-sm appearance-none bg-white" disabled> | |
| <option value="">Chargement...</option> | |
| </select> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <label for="inspector-name" class="block text-sm font-medium text-gray-700 mb-1">Nom inspecteur :</label> | |
| <input type="text" id="inspector-name" placeholder="Entrez votre nom" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"> | |
| </div> | |
| <div> | |
| <label for="inspection-site" class="block text-sm font-medium text-gray-700 mb-1">Site/Département :</label> | |
| <input type="text" id="inspection-site" placeholder="Ex: Site Nord, Département Production..." class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"> | |
| </div> | |
| </div> | |
| <button id="start-inspection-button" class="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-75 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center" disabled> | |
| <i class="fas fa-play mr-2"></i>Démarrer Inspection | |
| </button> | |
| </div> | |
| </div> | |
| <div class="bg-white p-6 rounded-lg shadow border border-gray-200"> | |
| <h3 class="text-lg font-medium mb-4 text-gray-800">Inspections Sauvegardées</h3> | |
| <div id="inspections-list" class="space-y-4"> | |
| <p class="text-gray-500 italic text-center py-4">Chargement...</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section Interface d'Inspection --> | |
| <section id="inspection-interface-section" class="hidden space-y-6"> | |
| <div class="flex items-center justify-between mb-6"> | |
| <div> | |
| <h2 class="text-2xl font-semibold text-gray-700" id="current-inspection-title">Inspection en cours</h2> | |
| <p class="text-sm text-gray-500" id="current-inspection-info">Informations de l'inspection</p> | |
| </div> | |
| <div class="flex gap-3"> | |
| <button id="save-inspection-progress" class="px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center"> | |
| <i class="fas fa-save mr-2"></i>Sauvegarder | |
| </button> | |
| <button id="finish-inspection" class="px-4 py-2 bg-green-600 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-400 flex items-center"> | |
| <i class="fas fa-check mr-2"></i>Terminer | |
| </button> | |
| <button id="exit-inspection" class="px-4 py-2 bg-gray-600 text-white font-semibold rounded-lg shadow-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 flex items-center"> | |
| <i class="fas fa-times mr-2"></i>Quitter | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Barre de progression --> | |
| <div class="bg-white p-4 rounded-lg shadow border border-gray-200"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span class="text-sm font-medium text-gray-700">Progression</span> | |
| <span class="text-sm text-gray-500" id="progress-text">0/0 points</span> | |
| </div> | |
| <div class="w-full bg-gray-200 rounded-full h-2"> | |
| <div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- Points de contrôle --> | |
| <div id="inspection-points-container" class="space-y-4"> | |
| <!-- Les points seront générés dynamiquement --> | |
| </div> | |
| </section> | |
| <!-- Section Tableau de Bord --> | |
| <section id="dashboard-section" class="hidden space-y-6"> | |
| <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4"> | |
| <h2 class="text-2xl font-semibold text-gray-700">Tableau de Bord</h2> | |
| <button id="export-dashboard-pdf" class="px-4 py-2 bg-purple-600 text-white font-semibold rounded-lg shadow-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-400 flex items-center"> | |
| <i class="fas fa-file-pdf mr-2"></i>Exporter PDF | |
| </button> | |
| </div> | |
| <!-- Filtres --> | |
| <div class="bg-white p-6 rounded-lg shadow border border-gray-200"> | |
| <h3 class="text-lg font-medium mb-4 text-gray-800">Filtres</h3> | |
| <div class="grid grid-cols-1 md:grid-cols-4 gap-4"> | |
| <div> | |
| <label for="dashboard-model-filter" class="block text-sm font-medium text-gray-700 mb-1">Modèle :</label> | |
| <select id="dashboard-model-filter" class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md shadow-sm appearance-none bg-white"> | |
| <option value="">Tous les modèles</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="dashboard-site-filter" class="block text-sm font-medium text-gray-700 mb-1">Site/Département :</label> | |
| <select id="dashboard-site-filter" class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md shadow-sm appearance-none bg-white"> | |
| <option value="">Tous les sites</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="dashboard-date-from" class="block text-sm font-medium text-gray-700 mb-1">Du :</label> | |
| <input type="date" id="dashboard-date-from" class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"> | |
| </div> | |
| <div> | |
| <label for="dashboard-date-to" class="block text-sm font-medium text-gray-700 mb-1">Au :</label> | |
| <input type="date" id="dashboard-date-to" class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"> | |
| </div> | |
| </div> | |
| <div class="mt-4"> | |
| <button id="apply-dashboard-filters" class="px-6 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400"> | |
| <i class="fas fa-filter mr-2"></i>Appliquer les filtres | |
| </button> | |
| </div> | |
| </div> | |
| <!-- KPIs --> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> | |
| <div class="kpi-card success"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm font-medium text-gray-600">Conformité Globale</p> | |
| <p id="kpi-conformity" class="text-2xl font-bold text-green-600">0%</p> | |
| </div> | |
| <i class="fas fa-check-circle text-3xl text-green-500"></i> | |
| </div> | |
| <p id="kpi-conformity-change" class="text-xs text-gray-500 mt-2">Aucune donnée</p> | |
| </div> | |
| <div class="kpi-card info"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm font-medium text-gray-600">Total Inspections</p> | |
| <p id="kpi-total-inspections" class="text-2xl font-bold text-blue-600">0</p> | |
| </div> | |
| <i class="fas fa-clipboard-list text-3xl text-blue-500"></i> | |
| </div> | |
| <p id="kpi-inspections-change" class="text-xs text-gray-500 mt-2">Aucune donnée</p> | |
| </div> | |
| <div class="kpi-card warning"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm font-medium text-gray-600">Hors Plage</p> | |
| <p id="kpi-out-of-range" class="text-2xl font-bold text-orange-600">0</p> | |
| </div> | |
| <i class="fas fa-exclamation-triangle text-3xl text-orange-500"></i> | |
| </div> | |
| <p id="kpi-out-of-range-change" class="text-xs text-gray-500 mt-2">Aucune donnée</p> | |
| </div> | |
| <div class="kpi-card danger"> | |
| <div class="flex items-center justify-between"> | |
| <div> | |
| <p class="text-sm font-medium text-gray-600">Non Conformes</p> | |
| <p id="kpi-non-conform" class="text-2xl font-bold text-red-600">0</p> | |
| </div> | |
| <i class="fas fa-times-circle text-3xl text-red-500"></i> | |
| </div> | |
| <p id="kpi-non-conform-change" class="text-xs text-gray-500 mt-2">Aucune donnée</p> | |
| </div> | |
| </div> | |
| <!-- Graphiques --> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| <div class="chart-container"> | |
| <h3 class="text-lg font-semibold mb-4">Évolution de la Conformité</h3> | |
| <div style="position: relative; height: 300px;"> | |
| <canvas id="conformity-trend-chart"></canvas> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <h3 class="text-lg font-semibold mb-4">Répartition par Statut</h3> | |
| <div style="position: relative; height: 300px;"> | |
| <canvas id="status-distribution-chart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <h3 class="text-lg font-semibold mb-4">Top 5 Non-Conformités par Catégorie</h3> | |
| <div style="position: relative; height: 300px;"> | |
| <canvas id="top-non-conformities-chart"></canvas> | |
| </div> | |
| </div> | |
| <!-- Tableau --> | |
| <div class="bg-white rounded-lg shadow border border-gray-200"> | |
| <div class="px-6 py-4 border-b border-gray-200"> | |
| <h3 class="text-lg font-semibold">Inspections Détaillées</h3> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="min-w-full divide-y divide-gray-200"> | |
| <thead class="bg-gray-50"> | |
| <tr> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Modèle</th> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Site</th> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Inspecteur</th> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Conformité</th> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Non-Conformes</th> | |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Hors Plage</th> | |
| </tr> | |
| </thead> | |
| <tbody id="dashboard-inspections-table" class="bg-white divide-y divide-gray-200"> | |
| <tr> | |
| <td colspan="7" class="px-6 py-4 text-center text-gray-500">Aucune inspection trouvée</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| </div> | |
| <!-- Modaux --> | |
| <div id="modal" class="modal" onclick="UI.closeModalOnClickOutside(event)"> | |
| <div class="modal-content"> | |
| <div class="flex justify-between items-center mb-4 border-b pb-2"> | |
| <h3 id="modal-title" class="text-xl font-semibold text-gray-800">Titre</h3> | |
| <button onclick="UI.closeModal()" class="text-gray-400 hover:text-gray-600 text-2xl font-bold">×</button> | |
| </div> | |
| <div id="modal-body" class="text-gray-700 mb-6">Contenu</div> | |
| <div id="modal-footer" class="mt-6 text-right space-x-3 border-t pt-4"></div> | |
| </div> | |
| </div> | |
| <div id="toast-container"></div> | |
| <div id="loading-indicator" style="display: none;"> | |
| <i class="fas fa-spinner fa-spin"></i> | |
| <p id="loading-message">Chargement...</p> | |
| </div> | |
| <script> | |
| (function() { | |
| 'use strict'; | |
| // Constants | |
| const MODELS_KEY = 'checklistApp_Models_v2'; | |
| const INSPECTIONS_KEY = 'checklistApp_Inspections_v2'; | |
| const INSPECTOR_NAME_KEY = 'checklistApp_InspectorName_v2'; | |
| // Global variables | |
| let currentModelData = {}; | |
| let currentInspectionsData = {}; | |
| let lastUsedInspectorName = ''; | |
| let dashboardCharts = {}; | |
| // Utilities | |
| const generateId = () => 'id_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |
| const formatDate = (dateString) => { | |
| if (!dateString) return 'N/A'; | |
| try { | |
| const date = new Date(dateString); | |
| if (isNaN(date.getTime())) return 'Date invalide'; | |
| return date.toLocaleString('fr-FR', { | |
| day: '2-digit', | |
| month: '2-digit', | |
| year: 'numeric', | |
| hour: '2-digit', | |
| minute: '2-digit' | |
| }); | |
| } catch (e) { | |
| console.error("Erreur date:", dateString, e); | |
| return 'Date invalide'; | |
| } | |
| }; | |
| // TemplateManager pour créer et télécharger le template Excel | |
| window.TemplateManager = { | |
| createExcelTemplate() { | |
| const templateData = [ | |
| { | |
| 'ID_Point': 'TEMP_001', | |
| 'Categorie': 'Température', | |
| 'PointDeControle': 'Température chambre froide positive', | |
| 'Description': 'Contrôle de la température de la chambre froide positive', | |
| 'CritereAcceptation': 'Entre 0°C et +4°C', | |
| 'TypeParametre': 'Numérique', | |
| 'OptionsParametre': '', | |
| 'ModeleCommentaire': 'Mesurer avec thermomètre étalonné toutes les 4h' | |
| }, | |
| { | |
| 'ID_Point': 'TEMP_002', | |
| 'Categorie': 'Température', | |
| 'PointDeControle': 'Température chambre froide négative', | |
| 'Description': 'Contrôle de la température de la chambre froide négative', | |
| 'CritereAcceptation': 'Inférieure à -18°C', | |
| 'TypeParametre': 'Numérique', | |
| 'OptionsParametre': '', | |
| 'ModeleCommentaire': 'Vérifier l\'étalonnage du thermomètre' | |
| }, | |
| { | |
| 'ID_Point': 'HYG_001', | |
| 'Categorie': 'Hygiène Personnel', | |
| 'PointDeControle': 'Port des équipements de protection', | |
| 'Description': 'Vérification du port correct des EPI', | |
| 'CritereAcceptation': 'Tous les EPI requis portés correctement', | |
| 'TypeParametre': 'Statut Seulement', | |
| 'OptionsParametre': '', | |
| 'ModeleCommentaire': 'Charlotte, blouse, chaussures de sécurité' | |
| }, | |
| { | |
| 'ID_Point': 'HYG_002', | |
| 'Categorie': 'Hygiène Personnel', | |
| 'PointDeControle': 'Lavage des mains', | |
| 'Description': 'Procédure de lavage des mains respectée', | |
| 'CritereAcceptation': 'Procédure complète de 30 secondes minimum', | |
| 'TypeParametre': 'Case à cocher', | |
| 'OptionsParametre': 'Savonnage adequat;Rinçage complet;Séchage avec papier;Désinfection', | |
| 'ModeleCommentaire': 'Observer la procédure complète' | |
| }, | |
| { | |
| 'ID_Point': 'NETT_001', | |
| 'Categorie': 'Nettoyage', | |
| 'PointDeControle': 'État de propreté des surfaces', | |
| 'Description': 'Contrôle visuel de la propreté des surfaces de travail', | |
| 'CritereAcceptation': 'Aucune souillure visible', | |
| 'TypeParametre': 'Liste Déroulante', | |
| 'OptionsParametre': 'Excellent;Bon;Moyen;Insuffisant;Inacceptable', | |
| 'ModeleCommentaire': 'Examiner les plans de travail, équipements' | |
| }, | |
| { | |
| 'ID_Point': 'DOC_001', | |
| 'Categorie': 'Documentation', | |
| 'PointDeControle': 'Enregistrements de nettoyage', | |
| 'Description': 'Vérification de la tenue des registres', | |
| 'CritereAcceptation': 'Tous les enregistrements à jour', | |
| 'TypeParametre': 'Texte', | |
| 'OptionsParametre': '', | |
| 'ModeleCommentaire': 'Vérifier les 7 derniers jours' | |
| }, | |
| { | |
| 'ID_Point': 'TRA_001', | |
| 'Categorie': 'Traçabilité', | |
| 'PointDeControle': 'Date limite de consommation', | |
| 'Description': 'Contrôle des DLC sur les produits stockés', | |
| 'CritereAcceptation': 'Aucun produit périmé', | |
| 'TypeParametre': 'Date', | |
| 'OptionsParametre': '', | |
| 'ModeleCommentaire': 'Contrôler un échantillon représentatif' | |
| } | |
| ]; | |
| return templateData; | |
| }, | |
| downloadTemplate() { | |
| console.log('Début téléchargement template Excel...'); | |
| try { | |
| if (typeof XLSX === 'undefined') { | |
| throw new Error('XLSX (SheetJS) non disponible'); | |
| } | |
| const templateData = this.createExcelTemplate(); | |
| console.log('Données template créées:', templateData.length, 'lignes'); | |
| const worksheet = XLSX.utils.json_to_sheet(templateData); | |
| // Définir la largeur des colonnes | |
| const columnWidths = [ | |
| { wpx: 80 }, // ID_Point | |
| { wpx: 120 }, // Categorie | |
| { wpx: 200 }, // PointDeControle | |
| { wpx: 250 }, // Description | |
| { wpx: 200 }, // CritereAcceptation | |
| { wpx: 120 }, // TypeParametre | |
| { wpx: 200 }, // OptionsParametre | |
| { wpx: 250 } // ModeleCommentaire | |
| ]; | |
| worksheet['!cols'] = columnWidths; | |
| const workbook = XLSX.utils.book_new(); | |
| XLSX.utils.book_append_sheet(workbook, worksheet, 'Template_Checklist'); | |
| // Ajouter une feuille d'instructions | |
| const instructionsData = [ | |
| { 'Colonne': 'ID_Point', 'Description': 'Identifiant unique du point de contrôle', 'Obligatoire': 'OUI', 'Exemple': 'TEMP_001' }, | |
| { 'Colonne': 'Categorie', 'Description': 'Catégorie du point de contrôle', 'Obligatoire': 'OUI', 'Exemple': 'Température' }, | |
| { 'Colonne': 'PointDeControle', 'Description': 'Nom du point de contrôle', 'Obligatoire': 'OUI', 'Exemple': 'Température chambre froide' }, | |
| { 'Colonne': 'Description', 'Description': 'Description détaillée (optionnel)', 'Obligatoire': 'NON', 'Exemple': 'Contrôle de la température...' }, | |
| { 'Colonne': 'CritereAcceptation', 'Description': 'Critères d\'acceptation (optionnel)', 'Obligatoire': 'NON', 'Exemple': 'Entre 0°C et +4°C' }, | |
| { 'Colonne': 'TypeParametre', 'Description': 'Type de paramètre à saisir', 'Obligatoire': 'OUI', 'Exemple': 'Numérique' }, | |
| { 'Colonne': 'OptionsParametre', 'Description': 'Options pour listes (séparées par ;)', 'Obligatoire': 'NON', 'Exemple': 'Bon;Moyen;Mauvais' }, | |
| { 'Colonne': 'ModeleCommentaire', 'Description': 'Commentaire modèle (optionnel)', 'Obligatoire': 'NON', 'Exemple': 'Mesurer toutes les 4h' } | |
| ]; | |
| const instructionsSheet = XLSX.utils.json_to_sheet(instructionsData); | |
| instructionsSheet['!cols'] = [ | |
| { wpx: 120 }, | |
| { wpx: 300 }, | |
| { wpx: 80 }, | |
| { wpx: 200 } | |
| ]; | |
| XLSX.utils.book_append_sheet(workbook, instructionsSheet, 'Instructions'); | |
| // Ajouter une feuille sur les types de paramètres | |
| const typesData = [ | |
| { 'Type': 'Texte', 'Description': 'Saisie libre de texte', 'Utilisation': 'Commentaires, observations' }, | |
| { 'Type': 'Numérique', 'Description': 'Saisie de nombres (température, pH, etc.)', 'Utilisation': 'Mesures, quantités' }, | |
| { 'Type': 'Date', 'Description': 'Saisie de dates', 'Utilisation': 'DLC, dates de contrôle' }, | |
| { 'Type': 'Liste Déroulante', 'Description': 'Choix dans une liste prédéfinie', 'Utilisation': 'États, niveaux de qualité' }, | |
| { 'Type': 'Case à cocher', 'Description': 'Multiple sélections possibles', 'Utilisation': 'Vérifications multiples' }, | |
| { 'Type': 'Statut Seulement', 'Description': 'Conforme/Non Conforme/Hors Plage/N.A.', 'Utilisation': 'Conformité simple avec boutons colorés' } | |
| ]; | |
| const typesSheet = XLSX.utils.json_to_sheet(typesData); | |
| typesSheet['!cols'] = [ | |
| { wpx: 120 }, | |
| { wpx: 250 }, | |
| { wpx: 200 } | |
| ]; | |
| XLSX.utils.book_append_sheet(workbook, typesSheet, 'Types_Parametres'); | |
| console.log('Workbook créé, tentative d\'écriture...'); | |
| XLSX.writeFile(workbook, 'Template_Checklist_Inspection.xlsx'); | |
| console.log('Fichier Excel écrit avec succès'); | |
| UI.showToast('Template Excel téléchargé avec succès !', 'success'); | |
| } catch (error) { | |
| console.error('Erreur téléchargement template:', error); | |
| UI.showToast('Erreur lors du téléchargement du template: ' + error.message, 'error'); | |
| } | |
| } | |
| }; | |
| // InspectionInterface pour gérer la saisie des inspections | |
| window.InspectionInterface = { | |
| currentInspectionId: null, | |
| continueInspection(inspectionId) { | |
| this.currentInspectionId = inspectionId; | |
| const inspection = currentInspectionsData[inspectionId]; | |
| if (!inspection) { | |
| UI.showToast('Inspection non trouvée.', 'error'); | |
| return; | |
| } | |
| const model = currentModelData[inspection.modelId]; | |
| if (!model) { | |
| UI.showToast('Modèle de l\'inspection non trouvé.', 'error'); | |
| return; | |
| } | |
| this.loadInspectionInterface(inspection, model); | |
| UI.switchView('inspection-interface'); | |
| }, | |
| loadInspectionInterface(inspection, model) { | |
| // Mettre à jour le titre et les infos | |
| document.getElementById('current-inspection-title').textContent = model.name || 'Inspection'; | |
| document.getElementById('current-inspection-info').textContent = | |
| `Inspecteur: ${inspection.inspectorName} | Site: ${inspection.site} | Démarrée: ${formatDate(inspection.startTime)}`; | |
| // Calculer et afficher la progression | |
| this.updateProgress(inspection, model); | |
| // Générer les points de contrôle | |
| this.generateInspectionPoints(inspection, model); | |
| }, | |
| updateProgress(inspection, model) { | |
| const progress = InspectionManager.calculateProgress(inspection); | |
| const progressText = document.getElementById('progress-text'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| if (progressText) { | |
| progressText.textContent = `${progress.completed}/${progress.total} points (${progress.percentage.toFixed(0)}%)`; | |
| } | |
| if (progressBar) { | |
| progressBar.style.width = progress.percentage + '%'; | |
| } | |
| }, | |
| generateInspectionPoints(inspection, model) { | |
| const container = document.getElementById('inspection-points-container'); | |
| if (!container) return; | |
| container.innerHTML = ''; | |
| // Grouper par catégorie | |
| const pointsByCategory = {}; | |
| model.points.forEach(point => { | |
| const category = point.Categorie || 'Général'; | |
| if (!pointsByCategory[category]) { | |
| pointsByCategory[category] = []; | |
| } | |
| pointsByCategory[category].push(point); | |
| }); | |
| // Créer les sections par catégorie | |
| Object.keys(pointsByCategory).sort().forEach(category => { | |
| const categorySection = document.createElement('div'); | |
| categorySection.className = 'bg-white rounded-lg shadow border border-gray-200'; | |
| const categoryHeader = document.createElement('div'); | |
| categoryHeader.className = 'bg-gray-50 px-6 py-3 border-b border-gray-200 cursor-pointer'; | |
| categoryHeader.innerHTML = `<h3 class="text-lg font-semibold text-gray-800 flex items-center"> | |
| <i class="fas fa-chevron-down mr-2 transition-transform"></i> | |
| ${category} | |
| <span class="ml-auto text-sm text-gray-500">${pointsByCategory[category].length} points</span> | |
| </h3>`; | |
| const categoryContent = document.createElement('div'); | |
| categoryContent.className = 'p-6 space-y-6'; | |
| // Ajouter les points de cette catégorie | |
| pointsByCategory[category].forEach(point => { | |
| const pointElement = this.createPointElement(point, inspection); | |
| categoryContent.appendChild(pointElement); | |
| }); | |
| // Toggle collapse/expand | |
| categoryHeader.addEventListener('click', () => { | |
| const icon = categoryHeader.querySelector('i'); | |
| if (categoryContent.style.display === 'none') { | |
| categoryContent.style.display = 'block'; | |
| icon.style.transform = 'rotate(0deg)'; | |
| } else { | |
| categoryContent.style.display = 'none'; | |
| icon.style.transform = 'rotate(-90deg)'; | |
| } | |
| }); | |
| categorySection.appendChild(categoryHeader); | |
| categorySection.appendChild(categoryContent); | |
| container.appendChild(categorySection); | |
| }); | |
| }, | |
| createPointElement(point, inspection) { | |
| const result = inspection.results[point.ID_Point] || { status: 'pending', value: '', comment: '', photos: [] }; | |
| const pointDiv = document.createElement('div'); | |
| pointDiv.className = 'border border-gray-200 rounded-lg p-4 bg-gray-50'; | |
| pointDiv.innerHTML = ` | |
| <div class="mb-3"> | |
| <h4 class="font-semibold text-gray-800">${point.PointDeControle}</h4> | |
| <p class="text-sm text-gray-600 mt-1">${point.Description || ''}</p> | |
| ${point.CritereAcceptation ? `<p class="text-sm text-blue-600 mt-1"><strong>Critère:</strong> ${point.CritereAcceptation}</p>` : ''} | |
| ${point.ModeleCommentaire ? `<p class="text-xs text-gray-500 italic mt-1">${point.ModeleCommentaire}</p>` : ''} | |
| </div> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Valeur/Résultat :</label> | |
| ${this.createInputField(point, result)} | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Statut :</label> | |
| ${point.TypeParametre === 'Statut Seulement' ? | |
| this.createStatusButtons(point, result) : | |
| `<select data-point="${point.ID_Point}" data-field="status" class="point-input block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"> | |
| <option value="pending" ${result.status === 'pending' ? 'selected' : ''}>En attente</option> | |
| <option value="Conforme" ${result.status === 'Conforme' ? 'selected' : ''}>Conforme</option> | |
| <option value="Non Conforme" ${result.status === 'Non Conforme' ? 'selected' : ''}>Non Conforme</option> | |
| <option value="Hors Plage" ${result.status === 'Hors Plage' ? 'selected' : ''}>Hors Plage</option> | |
| <option value="N/A" ${result.status === 'N/A' ? 'selected' : ''}>N/A</option> | |
| </select>` | |
| } | |
| </div> | |
| </div> | |
| <div class="mt-4"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Commentaire :</label> | |
| <textarea data-point="${point.ID_Point}" data-field="comment" class="point-input block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" rows="2" placeholder="Ajouter un commentaire...">${result.comment || ''}</textarea> | |
| </div> | |
| <div class="mt-4"> | |
| <label class="block text-sm font-medium text-gray-700 mb-2">Photos :</label> | |
| <div class="space-y-3"> | |
| <div class="flex flex-wrap gap-2"> | |
| <button type="button" onclick="InspectionInterface.openCamera('${point.ID_Point}')" class="px-3 py-2 bg-blue-500 text-white text-sm rounded shadow hover:bg-blue-600 flex items-center"> | |
| <i class="fas fa-camera mr-2"></i>Prendre Photo | |
| </button> | |
| <button type="button" onclick="InspectionInterface.selectPhoto('${point.ID_Point}')" class="px-3 py-2 bg-green-500 text-white text-sm rounded shadow hover:bg-green-600 flex items-center"> | |
| <i class="fas fa-image mr-2"></i>Sélectionner Photo | |
| </button> | |
| </div> | |
| <input type="file" id="photo-input-${point.ID_Point}" accept="image/*" style="display: none;" multiple> | |
| <div id="photos-container-${point.ID_Point}" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2"> | |
| ${this.renderPhotos(result.photos || [], point.ID_Point)} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| return pointDiv; | |
| }, | |
| // NOUVEAU: Créer des boutons de statut colorés | |
| createStatusButtons(point, result) { | |
| const statuses = [ | |
| { value: 'Conforme', label: 'C', class: 'conforme', title: 'Conforme' }, | |
| { value: 'Non Conforme', label: 'NC', class: 'non-conforme', title: 'Non Conforme' }, | |
| { value: 'Hors Plage', label: 'HP', class: 'hors-plage', title: 'Hors Plage' }, | |
| { value: 'N/A', label: 'NA', class: 'na', title: 'Non Applicable' } | |
| ]; | |
| return ` | |
| <div class="status-buttons"> | |
| ${statuses.map(status => ` | |
| <button type="button" | |
| class="status-button ${status.class} ${result.status === status.value ? 'active' : ''}" | |
| data-point="${point.ID_Point}" | |
| data-status="${status.value}" | |
| title="${status.title}" | |
| onclick="InspectionInterface.setStatus('${point.ID_Point}', '${status.value}')"> | |
| ${status.label} | |
| </button> | |
| `).join('')} | |
| </div> | |
| `; | |
| }, | |
| // NOUVEAU: Fonction pour définir le statut via les boutons | |
| setStatus(pointId, status) { | |
| if (!this.currentInspectionId) return; | |
| const inspection = currentInspectionsData[this.currentInspectionId]; | |
| if (!inspection) return; | |
| if (!inspection.results[pointId]) { | |
| inspection.results[pointId] = { status: 'pending', value: '', comment: '', photos: [] }; | |
| } | |
| inspection.results[pointId].status = status; | |
| // Mettre à jour l'affichage des boutons | |
| const buttons = document.querySelectorAll(`button[data-point="${pointId}"]`); | |
| buttons.forEach(button => { | |
| button.classList.remove('active'); | |
| if (button.dataset.status === status) { | |
| button.classList.add('active'); | |
| } | |
| }); | |
| // Sauvegarder automatiquement | |
| setTimeout(() => { | |
| this.saveProgress(); | |
| }, 500); | |
| }, | |
| renderPhotos(photos, pointId) { | |
| if (!photos || photos.length === 0) { | |
| return '<p class="text-gray-500 text-sm col-span-full">Aucune photo ajoutée</p>'; | |
| } | |
| return photos.map((photo, index) => ` | |
| <div class="relative group"> | |
| <img src="${photo.data}" alt="Photo ${index + 1}" class="w-full h-20 object-cover rounded border cursor-pointer hover:opacity-75" onclick="InspectionInterface.viewPhoto('${photo.data}', '${photo.name}')"> | |
| <button type="button" onclick="InspectionInterface.removePhoto('${pointId}', ${index})" class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| <p class="text-xs text-gray-600 mt-1 truncate">${photo.name}</p> | |
| </div> | |
| `).join(''); | |
| }, | |
| openCamera(pointId) { | |
| const input = document.getElementById(`photo-input-${pointId}`); | |
| if (input) { | |
| input.setAttribute('capture', 'camera'); | |
| input.click(); | |
| input.onchange = (event) => this.handlePhotoSelection(event, pointId); | |
| } | |
| }, | |
| selectPhoto(pointId) { | |
| const input = document.getElementById(`photo-input-${pointId}`); | |
| if (input) { | |
| input.removeAttribute('capture'); | |
| input.click(); | |
| input.onchange = (event) => this.handlePhotoSelection(event, pointId); | |
| } | |
| }, | |
| handlePhotoSelection(event, pointId) { | |
| const files = event.target.files; | |
| if (!files || files.length === 0) return; | |
| const inspection = currentInspectionsData[this.currentInspectionId]; | |
| if (!inspection) return; | |
| if (!inspection.results[pointId]) { | |
| inspection.results[pointId] = { status: 'pending', value: '', comment: '', photos: [] }; | |
| } | |
| if (!inspection.results[pointId].photos) { | |
| inspection.results[pointId].photos = []; | |
| } | |
| Array.from(files).forEach(file => { | |
| if (file.type.startsWith('image/')) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const photo = { | |
| name: file.name, | |
| data: e.target.result, | |
| timestamp: new Date().toISOString(), | |
| size: file.size | |
| }; | |
| inspection.results[pointId].photos.push(photo); | |
| // Limiter à 10 photos maximum par point | |
| if (inspection.results[pointId].photos.length > 10) { | |
| inspection.results[pointId].photos = inspection.results[pointId].photos.slice(-10); | |
| } | |
| // Mettre à jour l'affichage | |
| this.updatePhotosDisplay(pointId, inspection.results[pointId].photos); | |
| // Sauvegarder | |
| DataManager.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| UI.showToast('Photo ajoutée avec succès', 'success'); | |
| }; | |
| reader.readAsDataURL(file); | |
| } else { | |
| UI.showToast('Veuillez sélectionner un fichier image valide', 'warning'); | |
| } | |
| }); | |
| // Reset input | |
| event.target.value = ''; | |
| }, | |
| updatePhotosDisplay(pointId, photos) { | |
| const container = document.getElementById(`photos-container-${pointId}`); | |
| if (container) { | |
| container.innerHTML = this.renderPhotos(photos, pointId); | |
| } | |
| }, | |
| removePhoto(pointId, photoIndex) { | |
| const inspection = currentInspectionsData[this.currentInspectionId]; | |
| if (!inspection || !inspection.results[pointId] || !inspection.results[pointId].photos) return; | |
| inspection.results[pointId].photos.splice(photoIndex, 1); | |
| this.updatePhotosDisplay(pointId, inspection.results[pointId].photos); | |
| DataManager.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| UI.showToast('Photo supprimée', 'success'); | |
| }, | |
| viewPhoto(photoData, photoName) { | |
| const modalContent = ` | |
| <div class="text-center"> | |
| <img src="${photoData}" alt="${photoName}" class="max-w-full max-h-96 mx-auto rounded-lg shadow-lg"> | |
| <p class="mt-4 text-sm text-gray-600">${photoName}</p> | |
| <div class="mt-4 flex justify-center gap-3"> | |
| <button onclick="InspectionInterface.downloadPhoto('${photoData}', '${photoName}')" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"> | |
| <i class="fas fa-download mr-2"></i>Télécharger | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| UI.showModal('Aperçu Photo', modalContent, [ | |
| { text: 'Fermer', class: 'bg-gray-200 hover:bg-gray-300 text-gray-700', action: UI.closeModal } | |
| ]); | |
| }, | |
| downloadPhoto(photoData, photoName) { | |
| const link = document.createElement('a'); | |
| link.href = photoData; | |
| link.download = photoName || 'photo_inspection.jpg'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| UI.showToast('Photo téléchargée', 'success'); | |
| }, | |
| createInputField(point, result) { | |
| const baseClasses = 'point-input block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'; | |
| switch (point.TypeParametre) { | |
| case 'Numérique': | |
| return `<input type="number" step="any" data-point="${point.ID_Point}" data-field="value" class="${baseClasses}" placeholder="Entrer une valeur numérique" value="${result.value || ''}">`; | |
| case 'Date': | |
| return `<input type="date" data-point="${point.ID_Point}" data-field="value" class="${baseClasses}" value="${result.value || ''}">`; | |
| case 'Liste Déroulante': | |
| const options = point.OptionsParametre ? point.OptionsParametre.split(';') : []; | |
| let selectHtml = `<select data-point="${point.ID_Point}" data-field="value" class="${baseClasses}"> | |
| <option value="">-- Sélectionner --</option>`; | |
| options.forEach(option => { | |
| const selected = result.value === option.trim() ? 'selected' : ''; | |
| selectHtml += `<option value="${option.trim()}" ${selected}>${option.trim()}</option>`; | |
| }); | |
| selectHtml += '</select>'; | |
| return selectHtml; | |
| case 'Case à cocher': | |
| const checkOptions = point.OptionsParametre ? point.OptionsParametre.split(';') : []; | |
| const selectedValues = result.value ? result.value.split(',') : []; | |
| let checkboxHtml = '<div class="space-y-2">'; | |
| checkOptions.forEach((option, index) => { | |
| const checked = selectedValues.includes(option.trim()) ? 'checked' : ''; | |
| checkboxHtml += ` | |
| <label class="flex items-center"> | |
| <input type="checkbox" data-point="${point.ID_Point}" data-field="value" data-option="${option.trim()}" class="point-checkbox mr-2" ${checked}> | |
| <span class="text-sm">${option.trim()}</span> | |
| </label>`; | |
| }); | |
| checkboxHtml += '</div>'; | |
| return checkboxHtml; | |
| case 'Statut Seulement': | |
| return `<div class="text-sm text-gray-500 italic">Utilisez les boutons colorés ci-contre →</div>`; | |
| default: // Texte | |
| return `<input type="text" data-point="${point.ID_Point}" data-field="value" class="${baseClasses}" placeholder="Entrer du texte" value="${result.value || ''}">`; | |
| } | |
| }, | |
| saveProgress() { | |
| if (!this.currentInspectionId) return; | |
| const inspection = currentInspectionsData[this.currentInspectionId]; | |
| if (!inspection) return; | |
| // Sauvegarder tous les champs modifiés | |
| document.querySelectorAll('.point-input').forEach(input => { | |
| const pointId = input.dataset.point; | |
| const field = input.dataset.field; | |
| if (!inspection.results[pointId]) { | |
| inspection.results[pointId] = { status: 'pending', value: '', comment: '', photos: [] }; | |
| } | |
| if (field === 'value' && input.type === 'checkbox') { | |
| // Gérer les cases à cocher | |
| const checkboxes = document.querySelectorAll(`input[data-point="${pointId}"][data-field="value"]`); | |
| const selectedValues = []; | |
| checkboxes.forEach(cb => { | |
| if (cb.checked) { | |
| selectedValues.push(cb.dataset.option); | |
| } | |
| }); | |
| inspection.results[pointId][field] = selectedValues.join(','); | |
| } else { | |
| inspection.results[pointId][field] = input.value; | |
| } | |
| }); | |
| DataManager.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| // Mettre à jour la progression | |
| const model = currentModelData[inspection.modelId]; | |
| if (model) { | |
| this.updateProgress(inspection, model); | |
| } | |
| UI.showToast('Progression sauvegardée.', 'success'); | |
| }, | |
| finishInspection() { | |
| if (!this.currentInspectionId) return; | |
| const inspection = currentInspectionsData[this.currentInspectionId]; | |
| if (!inspection) return; | |
| // Sauvegarder d'abord | |
| this.saveProgress(); | |
| // Marquer comme terminée | |
| inspection.status = 'completed'; | |
| inspection.endTime = new Date().toISOString(); | |
| DataManager.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| UI.showToast('Inspection terminée avec succès !', 'success'); | |
| UI.switchView('inspections'); | |
| UI.displayInspections(); | |
| DashboardManager.updateDashboard(); | |
| }, | |
| exitInspection() { | |
| UI.showModal( | |
| 'Quitter l\'inspection', | |
| '<p>Voulez-vous sauvegarder vos modifications avant de quitter ?</p>', | |
| [ | |
| { text: 'Quitter sans sauvegarder', class: 'bg-gray-200 hover:bg-gray-300 text-gray-700', action: () => { UI.closeModal(); UI.switchView('inspections'); } }, | |
| { text: 'Sauvegarder et quitter', class: 'bg-blue-600 hover:bg-blue-700 text-white', action: () => { this.saveProgress(); UI.closeModal(); UI.switchView('inspections'); } } | |
| ] | |
| ); | |
| } | |
| }; | |
| const formatDateShort = (dateString) => { | |
| if (!dateString) return 'N/A'; | |
| try { | |
| const date = new Date(dateString); | |
| if (isNaN(date.getTime())) return 'Date invalide'; | |
| return date.toLocaleDateString('fr-FR'); | |
| } catch (e) { | |
| return 'Date invalide'; | |
| } | |
| }; | |
| // DataManager | |
| window.DataManager = { | |
| saveData(key, data) { | |
| try { | |
| localStorage.setItem(key, JSON.stringify(data)); | |
| console.log('Sauvé: ' + key); | |
| } catch (e) { | |
| console.error('Erreur save ' + key + ':', e); | |
| let message = "Erreur sauvegarde."; | |
| if (e.name === 'QuotaExceededError' || (e.code && (e.code === 22 || e.code === 1014))) { | |
| message = "Erreur: Stockage local plein."; | |
| UI.showModal("Stockage Plein", "Stockage local navigateur plein. Supprimez inspections/modèles."); | |
| } | |
| UI.showToast(message, 'error', 5000); | |
| } | |
| }, | |
| loadData(key, defaultValue) { | |
| if (defaultValue === undefined) defaultValue = {}; | |
| try { | |
| const data = localStorage.getItem(key); | |
| if (data === null) { | |
| console.log('Pas de data ' + key); | |
| return defaultValue; | |
| } | |
| console.log('Chargé: ' + key); | |
| return JSON.parse(data); | |
| } catch (e) { | |
| console.error('Erreur load ' + key + ':', e); | |
| UI.showToast('Erreur chargement (' + key + ').', 'error'); | |
| return defaultValue; | |
| } | |
| }, | |
| migrateData() { | |
| const oldInspections = this.loadData('checklistApp_Inspections_v1', {}); | |
| const oldModels = this.loadData('checklistApp_Models_v1', {}); | |
| if (Object.keys(oldInspections).length > 0 || Object.keys(oldModels).length > 0) { | |
| console.log("Migration des données v1 vers v2..."); | |
| Object.keys(oldModels).forEach(modelId => { | |
| if (!currentModelData[modelId]) { | |
| currentModelData[modelId] = oldModels[modelId]; | |
| } | |
| }); | |
| Object.keys(oldInspections).forEach(inspectionId => { | |
| if (!currentInspectionsData[inspectionId]) { | |
| const inspection = oldInspections[inspectionId]; | |
| inspection.site = inspection.site || 'Site non spécifié'; | |
| currentInspectionsData[inspectionId] = inspection; | |
| } | |
| }); | |
| this.saveData(MODELS_KEY, currentModelData); | |
| this.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| UI.showToast("Données migrées vers la version 2.1", 'info', 4000); | |
| } | |
| }, | |
| confirmClearAllData() { | |
| UI.showModal( | |
| "Confirmer suppression totale", | |
| "<p class='text-red-600 font-semibold'>ATTENTION !</p><p class='mt-2'>Suppression de <strong>tous les modèles et inspections</strong>.</p><p class='mt-2 font-bold'>Action IRREVERSIBLE.</p><p class='mt-3'>Continuer ?</p>", | |
| [ | |
| { text: "Annuler", class: "bg-gray-200 hover:bg-gray-300 text-gray-700", action: UI.closeModal }, | |
| { text: "Oui, Tout Supprimer", class: "bg-red-600 hover:bg-red-700 text-white font-bold", action: this.clearAllData.bind(this) } | |
| ] | |
| ); | |
| }, | |
| clearAllData() { | |
| try { | |
| localStorage.removeItem(MODELS_KEY); | |
| localStorage.removeItem(INSPECTIONS_KEY); | |
| localStorage.removeItem(INSPECTOR_NAME_KEY); | |
| localStorage.removeItem('checklistApp_Models_v1'); | |
| localStorage.removeItem('checklistApp_Inspections_v1'); | |
| localStorage.removeItem('checklistApp_InspectorName_v1'); | |
| currentModelData = {}; | |
| currentInspectionsData = {}; | |
| lastUsedInspectorName = ''; | |
| document.getElementById('inspector-name').value = ''; | |
| document.getElementById('inspection-site').value = ''; | |
| UI.displayModels(); | |
| UI.displayInspections(); | |
| DashboardManager.updateDashboard(); | |
| UI.switchView('welcome'); | |
| UI.closeModal(); | |
| UI.showToast("Données effacées.", 'success', 4000); | |
| } catch (e) { | |
| console.error("Erreur suppression:", e); | |
| UI.showToast("Erreur suppression.", 'error'); | |
| } | |
| }, | |
| saveInspectorName(name) { | |
| lastUsedInspectorName = name; | |
| this.saveData(INSPECTOR_NAME_KEY, name); | |
| }, | |
| loadInspectorName() { | |
| lastUsedInspectorName = this.loadData(INSPECTOR_NAME_KEY, ''); | |
| document.getElementById('inspector-name').value = lastUsedInspectorName; | |
| } | |
| }; | |
| // UI Object | |
| window.UI = { | |
| sections: ['welcome-section', 'models-section', 'inspections-section', 'inspection-interface-section', 'dashboard-section'], | |
| navButtons: ['nav-welcome', 'nav-models', 'nav-inspections', 'nav-dashboard'], | |
| sidebarLinks: ['sidebar-welcome', 'sidebar-models', 'sidebar-inspections', 'sidebar-dashboard'], | |
| switchView(viewId) { | |
| console.log('Switch view: ' + viewId); | |
| this.sections.forEach(id => { | |
| const element = document.getElementById(id); | |
| if (element) element.classList.add('hidden'); | |
| }); | |
| const targetSection = document.getElementById(viewId + '-section'); | |
| if (targetSection) targetSection.classList.remove('hidden'); | |
| this.navButtons.forEach(id => { | |
| const button = document.getElementById(id); | |
| if (button) { | |
| button.classList.remove('border-blue-500', 'text-blue-600', 'font-semibold'); | |
| button.classList.add('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700'); | |
| if (id === 'nav-' + viewId) { | |
| button.classList.remove('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700'); | |
| button.classList.add('border-blue-500', 'text-blue-600', 'font-semibold'); | |
| } | |
| } | |
| }); | |
| this.sidebarLinks.forEach(id => { | |
| const link = document.getElementById(id); | |
| if (link) { | |
| link.classList.remove('active'); | |
| if (id === 'sidebar-' + viewId) { | |
| link.classList.add('active'); | |
| } | |
| } | |
| }); | |
| if (viewId === 'dashboard') { | |
| // Attendre que le DOM soit bien mis à jour et Chart.js soit prêt | |
| setTimeout(() => { | |
| DashboardManager.initializeDashboard(); | |
| }, 300); | |
| } | |
| // Retourner à la liste des inspections si on quitte l'interface d'inspection | |
| if (InspectionInterface.currentInspectionId && viewId !== 'inspection-interface') { | |
| InspectionInterface.currentInspectionId = null; | |
| } | |
| window.scrollTo(0, 0); | |
| window.closeSidebar(); | |
| }, | |
| showModal(title, content, buttons) { | |
| if (!buttons) buttons = []; | |
| document.getElementById('modal-title').textContent = title; | |
| document.getElementById('modal-body').innerHTML = content; | |
| const footer = document.getElementById('modal-footer'); | |
| footer.innerHTML = ''; | |
| if (buttons.length === 0) { | |
| const closeButton = document.createElement('button'); | |
| closeButton.textContent = 'Fermer'; | |
| closeButton.className = 'px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400'; | |
| closeButton.onclick = UI.closeModal; | |
| footer.appendChild(closeButton); | |
| } else { | |
| buttons.forEach(btnInfo => { | |
| const button = document.createElement('button'); | |
| button.textContent = btnInfo.text; | |
| button.className = 'px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-opacity-75 ' + (btnInfo.class || 'bg-blue-500 hover:bg-blue-600 text-white'); | |
| if (!btnInfo.class || btnInfo.class.indexOf('focus:ring-') === -1) { | |
| button.classList.add('focus:ring-blue-400'); | |
| } | |
| button.onclick = btnInfo.action; | |
| footer.appendChild(button); | |
| }); | |
| } | |
| document.getElementById('modal').style.display = 'flex'; | |
| }, | |
| closeModal() { | |
| document.getElementById('modal').style.display = 'none'; | |
| }, | |
| closeModalOnClickOutside(event) { | |
| if (event.target === document.getElementById('modal')) { | |
| UI.closeModal(); | |
| } | |
| }, | |
| showHelpModal() { | |
| const helpContent = '<h4 class="font-semibold text-lg mb-3">Guide d\'utilisation (v2.1.0)</h4>' + | |
| '<div class="mb-4">' + | |
| '<h5 class="font-medium mb-2">🏠 Page d\'accueil</h5>' + | |
| '<p class="text-sm text-gray-600 mb-2">Présentation générale et guide de démarrage rapide.</p>' + | |
| '</div>' + | |
| '<div class="mb-4">' + | |
| '<h5 class="font-medium mb-2">📋 Gestion des modèles</h5>' + | |
| '<p class="text-sm text-gray-600 mb-2">Importez vos checklists depuis Excel (.xlsx).</p>' + | |
| '<ul class="text-xs text-gray-500 list-disc list-inside">' + | |
| '<li>Colonnes requises : ID_Point, Categorie, PointDeControle, TypeParametre</li>' + | |
| '<li>Types supportés : Texte, Numérique, Date, Liste Déroulante, Case à cocher, Statut Seulement</li>' + | |
| '</ul>' + | |
| '</div>' + | |
| '<div class="mb-4">' + | |
| '<h5 class="font-medium mb-2">✅ Inspections</h5>' + | |
| '<p class="text-sm text-gray-600 mb-2">Créez et gérez vos inspections avec la nouvelle interface de statuts colorés.</p>' + | |
| '<div class="flex gap-2 mt-2">' + | |
| '<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">C = Conforme</span>' + | |
| '<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">NC = Non Conforme</span>' + | |
| '<span class="px-2 py-1 bg-orange-100 text-orange-800 rounded text-xs">HP = Hors Plage</span>' + | |
| '<span class="px-2 py-1 bg-gray-100 text-gray-800 rounded text-xs">NA = Non Applicable</span>' + | |
| '</div>' + | |
| '</div>' + | |
| '<div class="mb-4">' + | |
| '<h5 class="font-medium mb-2">📊 Tableau de bord</h5>' + | |
| '<p class="text-sm text-gray-600 mb-2">Analysez vos performances avec des graphiques optimisés et indicateurs clés.</p>' + | |
| '</div>' + | |
| '<p class="text-sm font-medium text-orange-600 mt-4">⚠️ Données stockées localement - Exportez régulièrement !</p>' + | |
| '<p class="text-xs text-gray-500 mt-4">Version 2.1.0 - Améliorations graphiques et interface</p>'; | |
| this.showModal("Guide d'utilisation", helpContent); | |
| }, | |
| showToast(message, type, duration) { | |
| if (!type) type = 'info'; | |
| if (!duration) duration = 3500; | |
| const toastContainer = document.getElementById('toast-container'); | |
| if (!toastContainer) return; | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast toast-' + type; | |
| let iconClass = 'fas fa-info-circle'; | |
| if (type === 'success') iconClass = 'fas fa-check-circle'; | |
| else if (type === 'error') iconClass = 'fas fa-exclamation-triangle'; | |
| else if (type === 'warning') iconClass = 'fas fa-exclamation-circle'; | |
| toast.innerHTML = '<i class="' + iconClass + ' mr-2"></i> ' + message; | |
| toastContainer.appendChild(toast); | |
| requestAnimationFrame(function() { | |
| requestAnimationFrame(function() { | |
| toast.classList.add('show'); | |
| }); | |
| }); | |
| setTimeout(function() { | |
| toast.classList.remove('show'); | |
| setTimeout(function() { | |
| if (toast.parentElement === toastContainer) { | |
| toast.remove(); | |
| } | |
| }, 600); | |
| }, duration); | |
| }, | |
| setLoading(isLoading, message) { | |
| if (!message) message = "Chargement..."; | |
| const indicator = document.getElementById('loading-indicator'); | |
| const msgElement = document.getElementById('loading-message'); | |
| if (!indicator || !msgElement) return; | |
| if (isLoading) { | |
| msgElement.textContent = message; | |
| indicator.style.display = 'flex'; | |
| } else { | |
| setTimeout(function() { | |
| indicator.style.display = 'none'; | |
| }, 150); | |
| } | |
| }, | |
| displayModels() { | |
| const listContainer = document.getElementById('checklist-models-list'); | |
| const selectDropdown = document.getElementById('new-inspection-model'); | |
| const dashboardModelFilter = document.getElementById('dashboard-model-filter'); | |
| listContainer.innerHTML = ''; | |
| selectDropdown.innerHTML = '<option value="" disabled selected>-- Sélectionnez un modèle --</option>'; | |
| dashboardModelFilter.innerHTML = '<option value="">Tous les modèles</option>'; | |
| const modelIds = Object.keys(currentModelData).sort(function(a, b) { | |
| const nameA = currentModelData[a].name || ''; | |
| const nameB = currentModelData[b].name || ''; | |
| if (nameA.toLowerCase() < nameB.toLowerCase()) return -1; | |
| if (nameA.toLowerCase() > nameB.toLowerCase()) return 1; | |
| return new Date(currentModelData[b].importDate) - new Date(currentModelData[a].importDate); | |
| }); | |
| if (modelIds.length === 0) { | |
| listContainer.innerHTML = '<p class="text-gray-500 italic text-center py-4">Aucun modèle trouvé.</p>'; | |
| selectDropdown.innerHTML = '<option value="" disabled selected>Aucun modèle disponible</option>'; | |
| selectDropdown.disabled = true; | |
| } else { | |
| modelIds.forEach(function(modelId) { | |
| const model = currentModelData[modelId]; | |
| const modelName = model.name || 'Modèle importé ' + formatDate(model.importDate); | |
| const pointCount = (model.points && model.points.length) || 0; | |
| const listItem = document.createElement('div'); | |
| listItem.className = 'flex flex-col sm:flex-row justify-between items-start sm:items-center p-3 bg-gray-50 rounded border border-gray-200 hover:bg-gray-100 transition duration-150 gap-2'; | |
| listItem.innerHTML = '<div><span class="font-medium text-gray-800">' + modelName + '</span><span class="text-xs text-gray-500 ml-2">(' + pointCount + ' points)</span><p class="text-xs text-gray-400">ID: ' + modelId + '</p></div><button onclick="ModelManager.confirmDeleteModel(\'' + modelId + '\')" class="flex-shrink-0 px-3 py-1 bg-red-100 text-red-700 text-xs font-medium rounded hover:bg-red-200 hover:text-red-800 transition duration-150 self-end sm:self-center"><i class="fas fa-trash-alt mr-1"></i>Supprimer</button>'; | |
| listContainer.appendChild(listItem); | |
| const option = document.createElement('option'); | |
| option.value = modelId; | |
| option.textContent = modelName + ' (' + pointCount + ' points)'; | |
| selectDropdown.appendChild(option); | |
| const dashboardOption = document.createElement('option'); | |
| dashboardOption.value = modelId; | |
| dashboardOption.textContent = modelName; | |
| dashboardModelFilter.appendChild(dashboardOption); | |
| }); | |
| selectDropdown.disabled = false; | |
| } | |
| selectDropdown.dispatchEvent(new Event('change')); | |
| this.updateStartButtonState(); | |
| }, | |
| displayInspections() { | |
| const listContainer = document.getElementById('inspections-list'); | |
| listContainer.innerHTML = ''; | |
| const inspectionIds = Object.keys(currentInspectionsData).sort(function(a, b) { | |
| return new Date(currentInspectionsData[b].startTime) - new Date(currentInspectionsData[a].startTime); | |
| }); | |
| if (inspectionIds.length === 0) { | |
| listContainer.innerHTML = '<p class="text-gray-500 italic text-center py-4">Aucune inspection trouvée.</p>'; | |
| return; | |
| } | |
| inspectionIds.forEach(function(id) { | |
| const inspection = currentInspectionsData[id]; | |
| if (!inspection) return; | |
| const model = currentModelData[inspection.modelId]; | |
| const modelName = model ? (model.name || 'ID: ' + inspection.modelId) : 'Modèle Supprimé/Inconnu'; | |
| const statusText = inspection.status === 'completed' ? 'Terminée' : 'En cours'; | |
| const statusColor = inspection.status === 'completed' ? 'text-green-600 bg-green-100' : 'text-yellow-600 bg-yellow-100'; | |
| const progress = InspectionManager.calculateProgress(inspection); | |
| const isModelMissing = !model; | |
| const site = inspection.site || 'Site non spécifié'; | |
| const listItem = document.createElement('div'); | |
| listItem.className = 'p-4 bg-white rounded-lg shadow border border-gray-200 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3' + (isModelMissing ? ' opacity-70 border-l-4 border-red-400' : ''); | |
| listItem.innerHTML = '<div class="flex-grow">' + | |
| '<p class="font-semibold text-lg ' + (isModelMissing ? 'text-red-600' : 'text-blue-700') + '">' + modelName + '</p>' + | |
| '<p class="text-sm text-gray-600">Inspecteur: <span class="font-medium">' + (inspection.inspectorName || 'N/A') + '</span></p>' + | |
| '<p class="text-sm text-gray-600">Site: <span class="font-medium">' + site + '</span></p>' + | |
| '<p class="text-sm text-gray-500">Démarrée: ' + formatDate(inspection.startTime) + '</p>' + | |
| '<div class="flex items-center gap-2 mt-1">' + | |
| '<span class="text-xs font-medium px-2 py-0.5 rounded-full ' + statusColor + '">' + statusText + '</span>' + | |
| (inspection.status !== 'completed' ? '<span class="text-xs text-gray-500">(' + progress.percentage.toFixed(0) + '% complété)</span>' : '') + | |
| '</div>' + | |
| (inspection.status === 'completed' ? '<p class="text-sm text-gray-500 mt-1">Terminée: ' + formatDate(inspection.endTime) + '</p>' : '') + | |
| (isModelMissing ? '<p class="text-xs text-red-500 mt-1 font-semibold"><i class="fas fa-exclamation-triangle mr-1"></i>Modèle manquant !</p>' : '') + | |
| '</div>' + | |
| '<div class="flex flex-wrap gap-2 mt-2 sm:mt-0 self-end sm:self-center flex-shrink-0">' + | |
| (inspection.status !== 'completed' && !isModelMissing ? | |
| '<button onclick="InspectionInterface.continueInspection(\'' + id + '\')" class="px-3 py-1.5 bg-blue-500 text-white text-xs rounded shadow hover:bg-blue-700 flex items-center" title="Continuer"><i class="fas fa-play mr-1"></i> Continuer</button>' : '') + | |
| '<button onclick="InspectionManager.confirmDeleteInspection(\'' + id + '\')" class="px-3 py-1.5 bg-red-500 text-white text-xs rounded shadow hover:bg-red-700 flex items-center" title="Supprimer"><i class="fas fa-trash-alt mr-1"></i> Suppr.</button>' + | |
| '</div>'; | |
| listContainer.appendChild(listItem); | |
| }); | |
| }, | |
| updateStartButtonState() { | |
| const modelSelect = document.getElementById('new-inspection-model'); | |
| const inspectorInput = document.getElementById('inspector-name'); | |
| const siteInput = document.getElementById('inspection-site'); | |
| const startButton = document.getElementById('start-inspection-button'); | |
| if (modelSelect && inspectorInput && siteInput && startButton) { | |
| startButton.disabled = !modelSelect.value || !inspectorInput.value.trim() || !siteInput.value.trim(); | |
| } | |
| } | |
| }; | |
| // ModelManager | |
| window.ModelManager = { | |
| handleImport(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const importButton = document.getElementById('import-excel-button'); | |
| importButton.disabled = true; | |
| UI.setLoading(true, "Import modèle..."); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| try { | |
| const data = new Uint8Array(e.target.result); | |
| const workbook = XLSX.read(data, { type: 'array' }); | |
| const sheetName = workbook.SheetNames[0]; | |
| const worksheet = workbook.Sheets[sheetName]; | |
| const json = XLSX.utils.sheet_to_json(worksheet, { raw: false, defval: "" }); | |
| if (json.length === 0) { | |
| throw new Error("Fichier Excel vide."); | |
| } | |
| const requiredColumns = ['ID_Point', 'Categorie', 'PointDeControle', 'TypeParametre']; | |
| const firstRow = json[0]; | |
| const missingColumns = requiredColumns.filter(function(col) { | |
| return !(col in firstRow); | |
| }); | |
| if (missingColumns.length > 0) { | |
| throw new Error('Colonnes manquantes: ' + missingColumns.join(', ')); | |
| } | |
| const uniqueIds = new Set(); | |
| const processedPoints = json.map(function(row, rowIndex) { | |
| const idPoint = String(row.ID_Point || '').trim(); | |
| if (!idPoint) { | |
| throw new Error('ID_Point manquant ligne ' + (rowIndex + 2) + '.'); | |
| } | |
| if (uniqueIds.has(idPoint)) { | |
| throw new Error('ID_Point "' + idPoint + '" dupliqué (ligne ' + (rowIndex + 2) + ').'); | |
| } | |
| uniqueIds.add(idPoint); | |
| const typeParametre = String(row.TypeParametre || '').trim(); | |
| const validTypes = ['Texte', 'Numérique', 'Date', 'Liste Déroulante', 'Case à cocher', 'Statut Seulement']; | |
| if (!typeParametre || validTypes.indexOf(typeParametre) === -1) { | |
| throw new Error('TypeParametre invalide ("' + typeParametre + '") ligne ' + (rowIndex + 2) + '. Valides: ' + validTypes.join(', ')); | |
| } | |
| return { | |
| ID_Point: idPoint, | |
| Categorie: String(row.Categorie || 'Général').trim(), | |
| PointDeControle: String(row.PointDeControle || '').trim() || 'Point ' + idPoint, | |
| Description: String(row.Description || '').trim(), | |
| CritereAcceptation: String(row.CritereAcceptation || '').trim(), | |
| TypeParametre: typeParametre, | |
| OptionsParametre: String(row.OptionsParametre || '').trim(), | |
| ModeleCommentaire: String(row.ModeleCommentaire || '').trim() | |
| }; | |
| }); | |
| const modelId = generateId(); | |
| const newModel = { | |
| id: modelId, | |
| name: file.name.replace(/\.xlsx$/i, ''), | |
| importDate: new Date().toISOString(), | |
| points: processedPoints | |
| }; | |
| currentModelData[modelId] = newModel; | |
| DataManager.saveData(MODELS_KEY, currentModelData); | |
| UI.displayModels(); | |
| UI.showToast('Modèle "' + newModel.name + '" importé (' + newModel.points.length + ' points).', 'success'); | |
| } catch (error) { | |
| console.error("Erreur import Excel:", error); | |
| UI.showModal("Erreur Importation Excel", 'Erreur: ' + error.message); | |
| UI.showToast("Échec import modèle.", 'error'); | |
| } finally { | |
| UI.setLoading(false); | |
| document.getElementById('import-excel-input').value = ''; | |
| document.getElementById('import-excel-button').disabled = true; | |
| } | |
| }; | |
| reader.onerror = function() { | |
| UI.setLoading(false); | |
| UI.showToast("Erreur lecture fichier Excel.", 'error'); | |
| document.getElementById('import-excel-input').value = ''; | |
| document.getElementById('import-excel-button').disabled = true; | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| }, | |
| confirmDeleteModel(modelId) { | |
| const model = currentModelData[modelId]; | |
| if (!model) return; | |
| const modelName = model.name || 'ID: ' + modelId; | |
| const usedByInspections = Object.values(currentInspectionsData).filter(function(insp) { | |
| return insp.modelId === modelId; | |
| }); | |
| let message = '<p>Supprimer modèle "<strong>' + modelName + '</strong>" ?</p>'; | |
| if (usedByInspections.length > 0) { | |
| message += '<p class=\'mt-3 text-orange-600 font-semibold\'><i class="fas fa-exclamation-triangle mr-1"></i>Utilisé par ' + usedByInspections.length + ' inspection(s).</p>'; | |
| } | |
| message += '<p class=\'mt-3 font-bold\'>Action irréversible.</p>'; | |
| UI.showModal( | |
| "Confirmer Suppression Modèle", | |
| message, | |
| [ | |
| { text: "Annuler", class: "bg-gray-200 hover:bg-gray-300 text-gray-700", action: UI.closeModal }, | |
| { text: "Oui, Supprimer", class: "bg-red-600 hover:bg-red-700 text-white", action: function() { ModelManager.deleteModel(modelId); } } | |
| ] | |
| ); | |
| }, | |
| deleteModel(modelId) { | |
| const model = currentModelData[modelId]; | |
| if (!model) return; | |
| const modelName = model.name || 'ID: ' + modelId; | |
| delete currentModelData[modelId]; | |
| DataManager.saveData(MODELS_KEY, currentModelData); | |
| UI.displayModels(); | |
| UI.displayInspections(); | |
| DashboardManager.updateDashboard(); | |
| UI.closeModal(); | |
| UI.showToast('Modèle "' + modelName + '" supprimé.', 'success'); | |
| console.log('Modèle ' + modelId + ' supprimé.'); | |
| } | |
| }; | |
| // InspectionManager | |
| window.InspectionManager = { | |
| startInspection() { | |
| const modelId = document.getElementById('new-inspection-model').value; | |
| const inspectorName = document.getElementById('inspector-name').value.trim(); | |
| const site = document.getElementById('inspection-site').value.trim(); | |
| if (!modelId) { | |
| UI.showToast("Sélectionnez modèle.", 'warning'); | |
| return; | |
| } | |
| if (!inspectorName) { | |
| UI.showToast("Entrez nom inspecteur.", 'warning'); | |
| document.getElementById('inspector-name').focus(); | |
| return; | |
| } | |
| if (!site) { | |
| UI.showToast("Entrez site/département.", 'warning'); | |
| document.getElementById('inspection-site').focus(); | |
| return; | |
| } | |
| const model = currentModelData[modelId]; | |
| if (!model || !model.points) { | |
| UI.showToast("Erreur: Modèle invalide.", 'error'); | |
| return; | |
| } | |
| const inspectionId = generateId(); | |
| const initialResults = {}; | |
| model.points.forEach(function(point) { | |
| initialResults[point.ID_Point] = { status: 'pending', value: '', comment: '', photos: [] }; | |
| }); | |
| const newInspection = { | |
| id: inspectionId, | |
| modelId: modelId, | |
| inspectorName: inspectorName, | |
| site: site, | |
| startTime: new Date().toISOString(), | |
| endTime: null, | |
| status: 'in-progress', | |
| results: initialResults | |
| }; | |
| currentInspectionsData[inspectionId] = newInspection; | |
| DataManager.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| DataManager.saveInspectorName(inspectorName); | |
| UI.showToast('Inspection démarrée: ' + (model.name || modelId), 'success'); | |
| // Clear form | |
| document.getElementById('new-inspection-model').value = ''; | |
| document.getElementById('inspection-site').value = ''; | |
| UI.updateStartButtonState(); | |
| // Rediriger vers l'interface d'inspection | |
| InspectionInterface.continueInspection(inspectionId); | |
| }, | |
| confirmDeleteInspection(inspectionId) { | |
| const inspection = currentInspectionsData[inspectionId]; | |
| if (!inspection) return; | |
| const model = currentModelData[inspection.modelId]; | |
| const modelName = model ? model.name : 'ID Modèle: ' + inspection.modelId; | |
| const inspector = inspection.inspectorName || 'N/A'; | |
| const site = inspection.site || 'Site non spécifié'; | |
| const date = formatDate(inspection.startTime); | |
| const message = '<p>Supprimer inspection pour "<strong>' + modelName + '</strong>" ?</p>' + | |
| '<p class="text-sm text-gray-600 mt-1">Inspecteur: ' + inspector + '</p>' + | |
| '<p class="text-sm text-gray-600">Site: ' + site + '</p>' + | |
| '<p class="text-sm text-gray-600">Date: ' + date + '</p>' + | |
| '<p class="mt-3 font-bold text-red-600">Action irréversible.</p>'; | |
| UI.showModal( | |
| "Confirmer Suppression Inspection", | |
| message, | |
| [ | |
| { text: "Annuler", class: "bg-gray-200 hover:bg-gray-300 text-gray-700", action: UI.closeModal }, | |
| { text: "Oui, Supprimer", class: "bg-red-600 hover:bg-red-700 text-white", action: function() { InspectionManager.deleteInspection(inspectionId); } } | |
| ] | |
| ); | |
| }, | |
| deleteInspection(inspectionId) { | |
| if (!currentInspectionsData[inspectionId]) return; | |
| const inspection = currentInspectionsData[inspectionId]; | |
| const modelName = (currentModelData[inspection.modelId] && currentModelData[inspection.modelId].name) || 'ID: ' + inspection.modelId; | |
| delete currentInspectionsData[inspectionId]; | |
| DataManager.saveData(INSPECTIONS_KEY, currentInspectionsData); | |
| UI.displayInspections(); | |
| DashboardManager.updateDashboard(); | |
| UI.closeModal(); | |
| UI.showToast('Inspection "' + modelName + '" supprimée.', 'success'); | |
| }, | |
| calculateProgress(inspection) { | |
| if (!inspection || !inspection.modelId || !currentModelData[inspection.modelId]) { | |
| return { total: 0, completed: 0, percentage: 0 }; | |
| } | |
| const model = currentModelData[inspection.modelId]; | |
| const totalPoints = (model.points && model.points.length) || 0; | |
| let completedPoints = 0; | |
| if (totalPoints > 0 && inspection.results) { | |
| model.points.forEach(function(point) { | |
| const result = inspection.results[point.ID_Point]; | |
| if (result && result.status && result.status !== 'pending') { | |
| completedPoints++; | |
| } | |
| }); | |
| } | |
| const percentage = totalPoints > 0 ? (completedPoints / totalPoints) * 100 : 0; | |
| return { total: totalPoints, completed: completedPoints, percentage: percentage }; | |
| } | |
| }; | |
| // DashboardManager avec corrections pour les graphiques | |
| window.DashboardManager = { | |
| initializeDashboard() { | |
| this.populateFilters(); | |
| this.updateDashboard(); | |
| }, | |
| populateFilters() { | |
| const modelFilter = document.getElementById('dashboard-model-filter'); | |
| const siteFilter = document.getElementById('dashboard-site-filter'); | |
| modelFilter.innerHTML = '<option value="">Tous les modèles</option>'; | |
| Object.values(currentModelData).forEach(function(model) { | |
| const option = document.createElement('option'); | |
| option.value = model.id; | |
| option.textContent = model.name || 'Modèle ' + model.id; | |
| modelFilter.appendChild(option); | |
| }); | |
| siteFilter.innerHTML = '<option value="">Tous les sites</option>'; | |
| const sites = []; | |
| Object.values(currentInspectionsData).forEach(function(insp) { | |
| const site = insp.site || 'Site non spécifié'; | |
| if (sites.indexOf(site) === -1) { | |
| sites.push(site); | |
| } | |
| }); | |
| sites.sort().forEach(function(site) { | |
| const option = document.createElement('option'); | |
| option.value = site; | |
| option.textContent = site; | |
| siteFilter.appendChild(option); | |
| }); | |
| const today = new Date(); | |
| const thirtyDaysAgo = new Date(today); | |
| thirtyDaysAgo.setDate(today.getDate() - 30); | |
| document.getElementById('dashboard-date-from').value = thirtyDaysAgo.toISOString().split('T')[0]; | |
| document.getElementById('dashboard-date-to').value = today.toISOString().split('T')[0]; | |
| }, | |
| getFilteredInspections() { | |
| const modelFilter = document.getElementById('dashboard-model-filter').value; | |
| const siteFilter = document.getElementById('dashboard-site-filter').value; | |
| const dateFrom = document.getElementById('dashboard-date-from').value; | |
| const dateTo = document.getElementById('dashboard-date-to').value; | |
| return Object.values(currentInspectionsData).filter(function(inspection) { | |
| if (modelFilter && inspection.modelId !== modelFilter) return false; | |
| const inspectionSite = inspection.site || 'Site non spécifié'; | |
| if (siteFilter && inspectionSite !== siteFilter) return false; | |
| if (dateFrom || dateTo) { | |
| const inspectionDate = new Date(inspection.startTime); | |
| if (dateFrom && inspectionDate < new Date(dateFrom)) return false; | |
| if (dateTo && inspectionDate > new Date(dateTo + 'T23:59:59')) return false; | |
| } | |
| return true; | |
| }); | |
| }, | |
| updateDashboard() { | |
| const filteredInspections = this.getFilteredInspections(); | |
| this.updateKPIs(filteredInspections); | |
| this.updateCharts(filteredInspections); | |
| this.updateInspectionsTable(filteredInspections); | |
| }, | |
| updateKPIs(inspections) { | |
| const completedInspections = inspections.filter(function(insp) { | |
| return insp.status === 'completed'; | |
| }); | |
| if (completedInspections.length === 0) { | |
| document.getElementById('kpi-conformity').textContent = '0%'; | |
| document.getElementById('kpi-total-inspections').textContent = '0'; | |
| document.getElementById('kpi-out-of-range').textContent = '0'; | |
| document.getElementById('kpi-non-conform').textContent = '0'; | |
| ['kpi-conformity-change', 'kpi-inspections-change', 'kpi-out-of-range-change', 'kpi-non-conform-change'].forEach(function(id) { | |
| document.getElementById(id).textContent = 'Aucune donnée'; | |
| }); | |
| return; | |
| } | |
| let totalPoints = 0, conformePoints = 0, nonConformePoints = 0, horsPlagePoints = 0; | |
| completedInspections.forEach(function(inspection) { | |
| const model = currentModelData[inspection.modelId]; | |
| if (!model) return; | |
| model.points.forEach(function(point) { | |
| const result = inspection.results[point.ID_Point]; | |
| if (result && result.status && result.status !== 'pending') { | |
| totalPoints++; | |
| switch (result.status) { | |
| case 'Conforme': conformePoints++; break; | |
| case 'Non Conforme': nonConformePoints++; break; | |
| case 'Hors Plage': horsPlagePoints++; break; | |
| } | |
| } | |
| }); | |
| }); | |
| const conformityRate = totalPoints > 0 ? ((conformePoints / totalPoints) * 100) : 0; | |
| document.getElementById('kpi-conformity').textContent = conformityRate.toFixed(1) + '%'; | |
| document.getElementById('kpi-total-inspections').textContent = completedInspections.length.toString(); | |
| document.getElementById('kpi-out-of-range').textContent = horsPlagePoints.toString(); | |
| document.getElementById('kpi-non-conform').textContent = nonConformePoints.toString(); | |
| document.getElementById('kpi-conformity-change').textContent = 'Sur ' + totalPoints + ' points analysés'; | |
| document.getElementById('kpi-inspections-change').textContent = (inspections.length - completedInspections.length) + ' en cours'; | |
| document.getElementById('kpi-out-of-range-change').textContent = totalPoints > 0 ? ((horsPlagePoints / totalPoints) * 100).toFixed(1) + '% du total' : ''; | |
| document.getElementById('kpi-non-conform-change').textContent = totalPoints > 0 ? ((nonConformePoints / totalPoints) * 100).toFixed(1) + '% du total' : ''; | |
| }, | |
| updateCharts(inspections) { | |
| const completedInspections = inspections.filter(function(insp) { | |
| return insp.status === 'completed'; | |
| }); | |
| // Utiliser bind pour conserver le contexte et s'assurer que les graphiques sont créés après que les conteneurs soient visibles | |
| setTimeout(() => { | |
| this.createConformityTrendChart.call(this, completedInspections); | |
| this.createStatusDistributionChart.call(this, completedInspections); | |
| this.createTopNonConformitiesChart.call(this, completedInspections); | |
| }, 100); | |
| }, | |
| // CORRECTION: Fonction améliorée pour les graphiques avec gestion des dimensions | |
| createConformityTrendChart(inspections) { | |
| const ctx = document.getElementById('conformity-trend-chart'); | |
| if (!ctx) { | |
| console.warn('Canvas conformity-trend-chart non trouvé'); | |
| return; | |
| } | |
| // Vérifier que Chart.js est chargé | |
| if (typeof Chart === 'undefined') { | |
| console.error('Chart.js non chargé'); | |
| ctx.parentNode.innerHTML = '<p class="text-gray-500 text-center py-8">Chart.js non disponible</p>'; | |
| return; | |
| } | |
| const dateGroups = {}; | |
| inspections.forEach(function(inspection) { | |
| const date = new Date(inspection.startTime).toISOString().split('T')[0]; | |
| if (!dateGroups[date]) { | |
| dateGroups[date] = { total: 0, conforme: 0 }; | |
| } | |
| const model = currentModelData[inspection.modelId]; | |
| if (!model) return; | |
| model.points.forEach(function(point) { | |
| const result = inspection.results[point.ID_Point]; | |
| if (result && result.status && result.status !== 'pending') { | |
| dateGroups[date].total++; | |
| if (result.status === 'Conforme') { | |
| dateGroups[date].conforme++; | |
| } | |
| } | |
| }); | |
| }); | |
| const sortedDates = Object.keys(dateGroups).sort(); | |
| let labels, data; | |
| if (sortedDates.length === 0) { | |
| labels = ['Aucune donnée']; | |
| data = [0]; | |
| } else { | |
| labels = sortedDates.map(function(date) { | |
| return new Date(date).toLocaleDateString('fr-FR'); | |
| }); | |
| data = sortedDates.map(function(date) { | |
| const group = dateGroups[date]; | |
| return group.total > 0 ? (group.conforme / group.total) * 100 : 0; | |
| }); | |
| } | |
| // Détruire le graphique existant | |
| if (dashboardCharts.conformityTrend) { | |
| try { | |
| dashboardCharts.conformityTrend.destroy(); | |
| dashboardCharts.conformityTrend = null; | |
| } catch (e) { | |
| console.warn('Erreur destruction graphique conformité:', e); | |
| } | |
| } | |
| try { | |
| // CORRECTION: Configuration améliorée pour éviter les débordements | |
| dashboardCharts.conformityTrend = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: 'Taux de Conformité (%)', | |
| data: data, | |
| borderColor: '#10b981', | |
| backgroundColor: 'rgba(16, 185, 129, 0.1)', | |
| borderWidth: 2, | |
| fill: true, | |
| tension: 0.1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, // CORRECTION: Important pour contrôler la taille | |
| animation: { | |
| duration: 300 // Réduction de l'animation | |
| }, | |
| interaction: { | |
| intersect: false, | |
| mode: 'index' | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| max: 100, | |
| ticks: { | |
| callback: function(value) { | |
| return value + '%'; | |
| } | |
| } | |
| } | |
| }, | |
| plugins: { | |
| legend: { | |
| display: false | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| return context.parsed.y.toFixed(1) + '% de conformité'; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| console.log('Graphique conformité créé avec succès'); | |
| } catch (error) { | |
| console.error('Erreur création graphique conformité:', error); | |
| ctx.parentNode.innerHTML = '<p class="text-red-500 text-center py-8">Erreur chargement graphique conformité</p>'; | |
| } | |
| }, | |
| createStatusDistributionChart(inspections) { | |
| const ctx = document.getElementById('status-distribution-chart'); | |
| if (!ctx) { | |
| console.warn('Canvas status-distribution-chart non trouvé'); | |
| return; | |
| } | |
| if (typeof Chart === 'undefined') { | |
| console.error('Chart.js non chargé'); | |
| ctx.parentNode.innerHTML = '<p class="text-gray-500 text-center py-8">Chart.js non disponible</p>'; | |
| return; | |
| } | |
| let conforme = 0, nonConforme = 0, horsPlage = 0, na = 0; | |
| inspections.forEach(function(inspection) { | |
| const model = currentModelData[inspection.modelId]; | |
| if (!model) return; | |
| model.points.forEach(function(point) { | |
| const result = inspection.results[point.ID_Point]; | |
| if (result && result.status) { | |
| switch (result.status) { | |
| case 'Conforme': conforme++; break; | |
| case 'Non Conforme': nonConforme++; break; | |
| case 'Hors Plage': horsPlage++; break; | |
| case 'N/A': na++; break; | |
| } | |
| } | |
| }); | |
| }); | |
| const total = conforme + nonConforme + horsPlage + na; | |
| if (total === 0) { | |
| // Afficher un message si pas de données | |
| ctx.parentNode.innerHTML = '<div style="position: relative; height: 300px; display: flex; align-items: center; justify-content: center;"><p class="text-gray-500 text-center">Aucune donnée à afficher</p></div>'; | |
| return; | |
| } | |
| if (dashboardCharts.statusDistribution) { | |
| try { | |
| dashboardCharts.statusDistribution.destroy(); | |
| dashboardCharts.statusDistribution = null; | |
| } catch (e) { | |
| console.warn('Erreur destruction graphique distribution:', e); | |
| } | |
| } | |
| try { | |
| dashboardCharts.statusDistribution = new Chart(ctx, { | |
| type: 'doughnut', | |
| data: { | |
| labels: ['Conforme', 'Non Conforme', 'Hors Plage', 'N/A'], | |
| datasets: [{ | |
| data: [conforme, nonConforme, horsPlage, na], | |
| backgroundColor: ['#10b981', '#ef4444', '#f97316', '#6b7280'], | |
| borderWidth: 2, | |
| borderColor: '#ffffff' | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, // CORRECTION | |
| animation: { | |
| duration: 300 | |
| }, | |
| plugins: { | |
| legend: { | |
| position: 'bottom', | |
| labels: { | |
| padding: 15, | |
| usePointStyle: true | |
| } | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| const label = context.label || ''; | |
| const value = context.parsed; | |
| const percentage = ((value / total) * 100).toFixed(1); | |
| return label + ': ' + value + ' (' + percentage + '%)'; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| console.log('Graphique distribution créé avec succès'); | |
| } catch (error) { | |
| console.error('Erreur création graphique distribution:', error); | |
| ctx.parentNode.innerHTML = '<div style="position: relative; height: 300px; display: flex; align-items: center; justify-content: center;"><p class="text-red-500 text-center">Erreur chargement graphique distribution</p></div>'; | |
| } | |
| }, | |
| createTopNonConformitiesChart(inspections) { | |
| const ctx = document.getElementById('top-non-conformities-chart'); | |
| if (!ctx) { | |
| console.warn('Canvas top-non-conformities-chart non trouvé'); | |
| return; | |
| } | |
| if (typeof Chart === 'undefined') { | |
| console.error('Chart.js non chargé'); | |
| ctx.parentNode.innerHTML = '<p class="text-gray-500 text-center py-8">Chart.js non disponible</p>'; | |
| return; | |
| } | |
| const categoryNonConformities = {}; | |
| inspections.forEach(function(inspection) { | |
| const model = currentModelData[inspection.modelId]; | |
| if (!model) return; | |
| model.points.forEach(function(point) { | |
| const result = inspection.results[point.ID_Point]; | |
| if (result && (result.status === 'Non Conforme' || result.status === 'Hors Plage')) { | |
| const category = point.Categorie || 'Non classé'; | |
| categoryNonConformities[category] = (categoryNonConformities[category] || 0) + 1; | |
| } | |
| }); | |
| }); | |
| const sortedCategories = Object.entries(categoryNonConformities) | |
| .sort(function(a, b) { return b[1] - a[1]; }) | |
| .slice(0, 5); | |
| let labels, data; | |
| if (sortedCategories.length === 0) { | |
| ctx.parentNode.innerHTML = '<div style="position: relative; height: 300px; display: flex; align-items: center; justify-content: center;"><p class="text-gray-500 text-center">Aucune non-conformité détectée</p></div>'; | |
| return; | |
| } else { | |
| labels = sortedCategories.map(function(item) { return item[0]; }); | |
| data = sortedCategories.map(function(item) { return item[1]; }); | |
| } | |
| if (dashboardCharts.topNonConformities) { | |
| try { | |
| dashboardCharts.topNonConformities.destroy(); | |
| dashboardCharts.topNonConformities = null; | |
| } catch (e) { | |
| console.warn('Erreur destruction graphique non-conformités:', e); | |
| } | |
| } | |
| try { | |
| dashboardCharts.topNonConformities = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: 'Non-Conformités', | |
| data: data, | |
| backgroundColor: '#ef4444', | |
| borderColor: '#dc2626', | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, // CORRECTION | |
| animation: { | |
| duration: 300 | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| ticks: { | |
| stepSize: 1, | |
| callback: function(value) { | |
| return Number.isInteger(value) ? value : ''; | |
| } | |
| } | |
| } | |
| }, | |
| plugins: { | |
| legend: { | |
| display: false | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: function(context) { | |
| return context.parsed.y + ' non-conformité(s)'; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| console.log('Graphique non-conformités créé avec succès'); | |
| } catch (error) { | |
| console.error('Erreur création graphique non-conformités:', error); | |
| ctx.parentNode.innerHTML = '<div style="position: relative; height: 300px; display: flex; align-items: center; justify-content: center;"><p class="text-red-500 text-center">Erreur chargement graphique non-conformités</p></div>'; | |
| } | |
| }, | |
| updateInspectionsTable(inspections) { | |
| const tbody = document.getElementById('dashboard-inspections-table'); | |
| tbody.innerHTML = ''; | |
| if (inspections.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="7" class="px-6 py-4 text-center text-gray-500">Aucune inspection trouvée</td></tr>'; | |
| return; | |
| } | |
| inspections.sort(function(a, b) { | |
| return new Date(b.startTime) - new Date(a.startTime); | |
| }); | |
| inspections.forEach(function(inspection) { | |
| const model = currentModelData[inspection.modelId]; | |
| const modelName = model ? (model.name || 'Modèle ' + inspection.modelId) : 'Modèle supprimé'; | |
| const site = inspection.site || 'Site non spécifié'; | |
| let conforme = 0, nonConforme = 0, horsPlage = 0, totalPoints = 0; | |
| if (model && inspection.status === 'completed') { | |
| model.points.forEach(function(point) { | |
| const result = inspection.results[point.ID_Point]; | |
| if (result && result.status && result.status !== 'pending') { | |
| totalPoints++; | |
| switch (result.status) { | |
| case 'Conforme': conforme++; break; | |
| case 'Non Conforme': nonConforme++; break; | |
| case 'Hors Plage': horsPlage++; break; | |
| } | |
| } | |
| }); | |
| } | |
| const conformityRate = totalPoints > 0 ? ((conforme / totalPoints) * 100).toFixed(1) : 'N/A'; | |
| const row = document.createElement('tr'); | |
| row.className = 'hover:bg-gray-50'; | |
| row.innerHTML = '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' + formatDateShort(inspection.startTime) + '</td>' + | |
| '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' + modelName + '</td>' + | |
| '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' + site + '</td>' + | |
| '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' + (inspection.inspectorName || 'N/A') + '</td>' + | |
| '<td class="px-6 py-4 whitespace-nowrap text-sm"><span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' + | |
| (inspection.status === 'completed' ? | |
| (parseFloat(conformityRate) >= 90 ? 'bg-green-100 text-green-800' : | |
| parseFloat(conformityRate) >= 70 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800') | |
| : 'bg-gray-100 text-gray-800') + '">' + | |
| (inspection.status === 'completed' ? conformityRate + '%' : 'En cours') + '</span></td>' + | |
| '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' + nonConforme + '</td>' + | |
| '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">' + horsPlage + '</td>'; | |
| tbody.appendChild(row); | |
| }); | |
| } | |
| }; | |
| // Event Listeners Setup | |
| function setupEventListeners() { | |
| // Import Excel file input | |
| const importExcelInput = document.getElementById('import-excel-input'); | |
| const importExcelButton = document.getElementById('import-excel-button'); | |
| if (importExcelInput && importExcelButton) { | |
| importExcelInput.addEventListener('change', function(event) { | |
| importExcelButton.disabled = !event.target.files.length; | |
| }); | |
| importExcelButton.addEventListener('click', function() { | |
| if (importExcelInput.files.length > 0) { | |
| ModelManager.handleImport({ target: { files: importExcelInput.files } }); | |
| } | |
| }); | |
| } | |
| // Template download button | |
| const downloadTemplateButton = document.getElementById('download-template-button'); | |
| if (downloadTemplateButton) { | |
| downloadTemplateButton.addEventListener('click', function() { | |
| TemplateManager.downloadTemplate(); | |
| }); | |
| } | |
| // New inspection form | |
| const modelSelect = document.getElementById('new-inspection-model'); | |
| const inspectorInput = document.getElementById('inspector-name'); | |
| const siteInput = document.getElementById('inspection-site'); | |
| const startButton = document.getElementById('start-inspection-button'); | |
| if (modelSelect) { | |
| modelSelect.addEventListener('change', UI.updateStartButtonState); | |
| } | |
| if (inspectorInput) { | |
| inspectorInput.addEventListener('input', UI.updateStartButtonState); | |
| } | |
| if (siteInput) { | |
| siteInput.addEventListener('input', UI.updateStartButtonState); | |
| } | |
| if (startButton) { | |
| startButton.addEventListener('click', InspectionManager.startInspection); | |
| } | |
| // Inspection interface controls | |
| const saveProgressButton = document.getElementById('save-inspection-progress'); | |
| const finishInspectionButton = document.getElementById('finish-inspection'); | |
| const exitInspectionButton = document.getElementById('exit-inspection'); | |
| if (saveProgressButton) { | |
| saveProgressButton.addEventListener('click', () => InspectionInterface.saveProgress()); | |
| } | |
| if (finishInspectionButton) { | |
| finishInspectionButton.addEventListener('click', () => InspectionInterface.finishInspection()); | |
| } | |
| if (exitInspectionButton) { | |
| exitInspectionButton.addEventListener('click', () => InspectionInterface.exitInspection()); | |
| } | |
| // Dashboard filters | |
| const applyFiltersButton = document.getElementById('apply-dashboard-filters'); | |
| if (applyFiltersButton) { | |
| applyFiltersButton.addEventListener('click', () => DashboardManager.updateDashboard()); | |
| } | |
| // Export PDF button | |
| const exportPdfButton = document.getElementById('export-dashboard-pdf'); | |
| if (exportPdfButton) { | |
| exportPdfButton.addEventListener('click', function() { | |
| UI.showToast('Export PDF sera implémenté dans une future version.', 'info'); | |
| }); | |
| } | |
| // Auto-save lors de la saisie dans l'interface d'inspection | |
| document.addEventListener('input', function(event) { | |
| if (event.target.classList.contains('point-input')) { | |
| // Auto-save après un court délai pour éviter trop de sauvegardes | |
| clearTimeout(window.autoSaveTimeout); | |
| window.autoSaveTimeout = setTimeout(() => { | |
| if (InspectionInterface.currentInspectionId) { | |
| InspectionInterface.saveProgress(); | |
| } | |
| }, 2000); // 2 secondes de délai | |
| } | |
| }); | |
| document.addEventListener('change', function(event) { | |
| if (event.target.classList.contains('point-input') || event.target.classList.contains('point-checkbox')) { | |
| // Sauvegarde immédiate pour les changements de sélection | |
| if (InspectionInterface.currentInspectionId) { | |
| setTimeout(() => { | |
| InspectionInterface.saveProgress(); | |
| }, 500); | |
| } | |
| } | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', function(event) { | |
| // Ctrl+S pour sauvegarder dans l'interface d'inspection | |
| if (event.ctrlKey && event.key === 's' && InspectionInterface.currentInspectionId) { | |
| event.preventDefault(); | |
| InspectionInterface.saveProgress(); | |
| } | |
| // Escape pour quitter l'interface d'inspection | |
| if (event.key === 'Escape' && InspectionInterface.currentInspectionId) { | |
| InspectionInterface.exitInspection(); | |
| } | |
| }); | |
| } | |
| // Sidebar functions | |
| window.openSidebar = function() { | |
| const sidebar = document.getElementById('sidebar'); | |
| const mainContent = document.getElementById('main-content'); | |
| const menuToggle = document.getElementById('menu-toggle'); | |
| if (sidebar) sidebar.style.width = '280px'; | |
| if (mainContent) mainContent.style.marginLeft = '280px'; | |
| if (menuToggle) menuToggle.style.left = '290px'; | |
| }; | |
| window.closeSidebar = function() { | |
| const sidebar = document.getElementById('sidebar'); | |
| const mainContent = document.getElementById('main-content'); | |
| const menuToggle = document.getElementById('menu-toggle'); | |
| if (sidebar) sidebar.style.width = '0'; | |
| if (mainContent) mainContent.style.marginLeft = '0'; | |
| if (menuToggle) menuToggle.style.left = '1rem'; | |
| }; | |
| // Initialize application | |
| function initializeApplication() { | |
| console.log('Initialisation de l\'application d\'inspection qualité v2.1.0'); | |
| try { | |
| // Vérifier les dépendances | |
| if (typeof XLSX === 'undefined') { | |
| console.warn('XLSX (SheetJS) non chargé - Import Excel indisponible'); | |
| } | |
| if (typeof Chart === 'undefined') { | |
| console.warn('Chart.js non chargé - Graphiques indisponibles'); | |
| } | |
| // Load data from localStorage | |
| currentModelData = DataManager.loadData(MODELS_KEY, {}); | |
| currentInspectionsData = DataManager.loadData(INSPECTIONS_KEY, {}); | |
| // Migrate data if needed | |
| DataManager.migrateData(); | |
| // Load inspector name | |
| DataManager.loadInspectorName(); | |
| // Setup event listeners | |
| setupEventListeners(); | |
| // Initialize UI | |
| UI.displayModels(); | |
| UI.displayInspections(); | |
| // Set initial view to welcome | |
| UI.switchView('welcome'); | |
| console.log('Application initialisée avec succès'); | |
| console.log('Modèles chargés:', Object.keys(currentModelData).length); | |
| console.log('Inspections chargées:', Object.keys(currentInspectionsData).length); | |
| } catch (error) { | |
| console.error('Erreur lors de l\'initialisation:', error); | |
| UI.showToast('Erreur d\'initialisation de l\'application.', 'error'); | |
| } | |
| } | |
| // Start application when DOM is loaded | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initializeApplication); | |
| } else { | |
| initializeApplication(); | |
| } | |
| // Global error handler | |
| window.addEventListener('error', function(event) { | |
| console.error('Erreur globale:', event.error); | |
| UI.showToast('Erreur inattendue. Consultez la console.', 'error'); | |
| }); | |
| // Handle unhandled promise rejections | |
| window.addEventListener('unhandledrejection', function(event) { | |
| console.error('Promise rejection non gérée:', event.reason); | |
| UI.showToast('Erreur de traitement. Consultez la console.', 'error'); | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |