Spaces:
Running
Running
| class DataProcessor { | |
| constructor(state, uiManager) { | |
| this.state = state; | |
| this.uiManager = uiManager; | |
| this.state.subscribe(this.onStateChange.bind(this)); | |
| } | |
| static REVIEW_CHECKLIST_STRUCTURE = { | |
| "1_documents_et_dossier": { | |
| "titre": "Réception et complétude des documents", | |
| "items": [ | |
| { "id": "doc_001", "nom": "Mandat d'audit" }, | |
| { "id": "doc_002", "nom": "Plan d'audit" }, | |
| { "id": "doc_003", "nom": "Bilan de clôture" }, | |
| { "id": "doc_004", "nom": "Éléments obligatoires complétés" }, | |
| { "id": "doc_005", "nom": "Synthèse" }, | |
| { "id": "doc_006", "nom": "Rapport AXP" }, | |
| { "id": "doc_007", "nom": "Plan d'actions complété" }, | |
| { "id": "doc_008", "nom": "Preuves de correction" }, | |
| { "id": "doc_009", "nom": "Notes d'audit" } | |
| ] | |
| }, | |
| "2_audits_a_distance": { | |
| "titre": "Spécificités audits à distance", | |
| "sous_titres": "Applicable pour : audits IFS Broker à distance + audits siège à distance", | |
| "items": [ | |
| { "id": "dist_001", "nom": "Captures d'écran début d'audit", "description": "Participants, date et heure visibles" }, | |
| { "id": "dist_002", "nom": "Captures d'écran fin d'audit", "description": "Participants, date et heure visibles" }, | |
| { "id": "dist_003", "nom": "Enregistrement historique de conservation", "description": "Si applicable selon l'outil utilisé" }, | |
| { "id": "dist_004", "nom": "Preuve test de connexion", "description": "Capture d'écran + date/résultat complétés dans le mandat" }, | |
| { "id": "dist_005", "nom": "Analyse de risques CRO", "description": "Complétée par le Coordinateur Responsable de l'Organisme" } | |
| ] | |
| }, | |
| "3_coherence_durees": { | |
| "titre": "Cohérence des durées d'audit", | |
| "items": [ | |
| { "id": "dur_001", "nom": "Cohérence mandat ↔ plan d'audit", "description": "Durée identique entre les deux documents" }, | |
| { "id": "dur_002", "nom": "Cohérence mandat ↔ synthèse", "description": "Durée mandat = durée réellement passée (synthèse)" }, | |
| { "id": "dur_003", "nom": "Cohérence rapport ↔ calculateur IFS", "description": "Joindre l'outil de calcul", "justification": "" }, | |
| { "id": "dur_004", "nom": "Temps passé en usine", "description": "Temps effectif en site ou justification documentée", "justification": "" } | |
| ] | |
| }, | |
| "4_statut_rapport": { | |
| "titre": "Statut et complétude du rapport", | |
| "items": [ | |
| { "id": "rap_001", "nom": "Version du rapport", "description": "Statut 'non périmé' et version finalisée" }, | |
| { "id": "rap_002", "nom": "Données manquantes", "description": "Absence de données manquantes (sauf nom/prénom CDO et date décision à compléter)", "exceptions": ["CDO nom/prénom", "Date de décision"] } | |
| ] | |
| }, | |
| "5_donnees_entreprise": { | |
| "titre": "Informations de l'entreprise - Profil", | |
| "items": [ | |
| { "id": "prof_001", "nom": "Nombre maximum d'employés", "description": "Au pic de l'activité" }, | |
| { "id": "prof_002", "nom": "Surface totale du site", "description": "Production + stockage (m²)" }, | |
| { "id": "prof_003", "nom": "Activité saisonnière", "description": "Renseignée si applicable" }, | |
| { "id": "prof_004", "nom": "Produits totalement sous-traités", "description": "Nom entreprise, localisation, certification IFS, COID si applicable" }, | |
| { "id": "prof_005", "nom": "Produits de négoce", "description": "Nom entreprise, localisation, certification IFS, COID si applicable" }, | |
| { "id": "prof_006", "nom": "Procédés partiellement sous-traités", "description": "Nom entreprise, localisation, certification IFS, COID si applicable" }, | |
| { "id": "prof_007", "nom": "Usage du logo IFS", "description": "Conforme à la réglementation" } | |
| ] | |
| }, | |
| "6_donnees_audit": { | |
| "titre": "Données de l'audit / Évaluation", | |
| "items": [ | |
| { "id": "eval_001", "nom": "Produits et procédés audités", "description": "Liste des produits et procédés vus lors de l'audit sur site" }, | |
| { "id": "eval_002", "nom": "Option d'audit", "description": "Annoncée ou non annoncée" }, | |
| { "id": "eval_003", "nom": "Certification IFS précédente", "description": "Date et fin de validité du certificat antérieur" }, | |
| { "id": "eval_004", "nom": "Personne en charge de la revue", "description": "Identifiée et documentée" }, | |
| { "id": "eval_005", "nom": "Horaires d'audit", "description": "Sans les pauses + justification en cas de dépassement/réduction", "justification": "" }, | |
| { "id": "eval_006", "nom": "Auditeurs et participants", "description": "Direction, RQ, traducteur et observateur éventuels (optionnel hors Direction/RQ)" } | |
| ] | |
| }, | |
| "7_secteurs_scope": { | |
| "titre": "Secteurs / Scope Data", | |
| "items": [ | |
| { "id": "scope_001", "nom": "Description complète des procédés", "description": "Vue complète + secteurs technologiques" }, | |
| { "id": "scope_002", "nom": "Périmètre d'audit", "description": "Libellé + traduction anglaise" }, | |
| { "id": "scope_003", "nom": "Exclusions", "description": "Documentées pour le certificat avec justification dans 'informations additionnelles'" }, | |
| { "id": "scope_004", "nom": "Outils calcul durée", "description": "Secteurs technologiques sélectionnés pour chaque secteur produit" }, | |
| { "id": "scope_005", "nom": "Sous-catégories de produits", "description": "Exhaustivité et conformité vérifiées" } | |
| ] | |
| }, | |
| "8_audits_multisites": { | |
| "titre": "Organisation de l'audit en multi-sites", | |
| "items": [ | |
| { "id": "multi_001", "nom": "Plan d'audit adapté", "description": "Identification des chapitres audités une seule fois pour tous les sites" }, | |
| { "id": "multi_002", "nom": "Description des autres sites", "description": "Nom, localisation, statut certification IFS, COID si applicable" }, | |
| { "id": "multi_003", "nom": "Organisation audit multisites", "description": "Activités communes auditées une fois + services centraux (date/lieu)" }, | |
| { "id": "multi_004", "nom": "Répercution écarts siège", "description": "Plan d'actions et rapport complétés" }, | |
| { "id": "multi_005", "nom": "Onglet multisites synthèse", "description": "Cas des audits multi-sites complété" } | |
| ] | |
| }, | |
| "9_pertinence_deviations": { | |
| "titre": "Pertinence des déviations", | |
| "items": [ | |
| { "id": "dev_001", "nom": "Justification des déviations", "description": "Notes et justification documentées" }, | |
| { "id": "dev_002", "nom": "Suivi actions correctives précédentes", "description": "Suivi des NC/déviations de l'audit antérieur" } | |
| ] | |
| }, | |
| "10_plan_actions": { | |
| "titre": "Plan d'actions", | |
| "items": [ | |
| { "id": "pa_001", "nom": "Pertinence corrections", "description": "Actions correctives proposées par l'entreprise pertinentes et efficaces" }, | |
| { "id": "pa_002", "nom": "Délais corrections", "description": "Antérieurs à envoi PA à OC ou certification du dossier" }, | |
| { "id": "pa_003", "nom": "Délais actions correctives", "description": "Avant ouverture prochaine fenêtre audit selon gravité NC/déviation" }, | |
| { "id": "pa_004", "nom": "Statuts mise en place", "description": "Corrections et actions correctives renseignées" }, | |
| { "id": "pa_005", "nom": "Validation auditeur", "description": "Statut 'OK-Libéré-Validé-Approuvé', nom auditeur, date validation" } | |
| ] | |
| }, | |
| "11_preuves_corrections": { | |
| "titre": "Preuves de corrections", | |
| "items": [ | |
| { "id": "prev_001", "nom": "Pertinence des preuves", "description": "Preuves documentées et pertinentes pour chaque correction" } | |
| ] | |
| }, | |
| "12_checklist": { | |
| "titre": "Check-list audit", | |
| "items": [ | |
| { "id": "ckl_001", "nom": "Champs obligatoires complétés", "description": "Tous les champs obligatoires renseignés" }, | |
| { "id": "ckl_002", "nom": "NA justifiés", "description": "Non Applicable justifiés et documentés" } | |
| ] | |
| }, | |
| "13_traductions_anglais": { | |
| "titre": "Traduction en anglais", | |
| "items": [ | |
| { "id": "tra_001", "nom": "Traduction rapport", "description": "Tout traduit sauf les NA" }, | |
| { "id": "tra_002", "nom": "Champs obligatoires", "description": "Seule version anglaise requise pour export rapport" }, | |
| { "id": "tra_003", "nom": "Déviations et NC", "description": "Langue audit + traduction anglaise" }, | |
| { "id": "tra_004", "nom": "Plan d'actions", "description": "Traduction déviations, NC, corrections, actions correctives" } | |
| ] | |
| } | |
| }; | |
| setUIManager(uiManager) { | |
| this.uiManager = uiManager; | |
| } | |
| onStateChange(newState) { | |
| // Handle state changes here if needed | |
| console.log('DataProcessor State changed:', newState); | |
| if (newState.currentMode === 'auditor') { | |
| this.renderAuditorTaskList(); | |
| } | |
| // Re-apply filters whenever the state changes. | |
| // This is called after the UIManager has re-rendered the tables. | |
| this.filterProfileTable(); | |
| this.filterChecklist(); | |
| this.filterNonConformities(); | |
| } | |
| updateElementText(elementId, text) { | |
| const element = document.getElementById(elementId); | |
| if (element) { | |
| element.textContent = text; | |
| } else { | |
| // console.error(`Element with id ${elementId} not found`); // Suppress for now, many elements are dynamic | |
| } | |
| } | |
| processAuditDataLogic(food8) { | |
| console.log('🎯 PROCESSING NEW IFS FILE'); | |
| try { | |
| // 1. Extraction Immédiate des Infos de Base et du Profil | |
| // On le fait AVANT la checklist pour afficher les infos même si la checklist échoue | |
| const companyName = food8.questions?.companyName?.answer || 'Société inconnue'; | |
| const coid = food8.questions?.companyCoid?.answer || 'COID-XXXX'; | |
| // Mise à jour de l'interface | |
| this.uiManager.updateCurrentAuditName(`IFS Reviewer - ${companyName}`); | |
| this.uiManager.updateSessionInfo(coid); | |
| // CHANGEMENT : On passe TOUT l'objet food8 pour extraire aussi les infos hors 'questions' | |
| const companyProfileData = this.extractCompanyProfile(food8); | |
| const auditData = this.state.get().auditData; | |
| const conversations = {}; | |
| // Mise à jour du State partiel (Profil) | |
| this.state.setState({ | |
| auditData: auditData, // Conserve existing or new? Actually file-handler sets auditData I think? default to existing if not passed? | |
| // Wait, file-handler normally doesn't set auditData state before calling this? | |
| // Actually auditData IS passed in food8 usually? No "food8" IS the auditData. | |
| // We should probably save food8 as auditData in state. | |
| auditData: food8, | |
| companyProfileData: companyProfileData, | |
| conversations: conversations, | |
| currentSession: { | |
| ...this.state.get().currentSession, | |
| id: `IFS-${coid}-${new Date().getTime()}`, | |
| name: `Audit ${companyName}`, | |
| data: food8 | |
| } | |
| }); | |
| // Rendu du Profil Immédiat | |
| this.renderCompanyProfile(); | |
| // Calcul des Scores (si disponibles) | |
| if (food8.matrixResult) { | |
| let totalA = 0, totalB = 0, totalC = 0, totalD = 0, totalNA = 0; | |
| food8.matrixResult.forEach(item => { | |
| if (item.type === 'scoreCount') { | |
| switch (item.scoreId) { | |
| case 'A': totalA += item.count; break; | |
| case 'B': totalB += item.count; break; | |
| case 'C': totalC += item.count; break; | |
| case 'D': totalD += item.count; break; | |
| case 'NA': totalNA += item.count; break; | |
| } | |
| } | |
| }); | |
| const totalRequirements = totalA + totalB + totalC + totalD + totalNA; | |
| this.uiManager.updateElementText('totalRequirements', totalRequirements); | |
| this.uiManager.updateElementText('conformCount', totalA); | |
| this.uiManager.updateElementText('nonConformCount', totalB + totalC + totalD); | |
| if (food8.result?.overall?.percent) { | |
| this.uiManager.updateElementText('overallScore', food8.result.overall.percent.toFixed(1) + '%'); | |
| } | |
| } | |
| // 2. Traitement Asynchrone de la Checklist | |
| this.processChecklistData(food8.checklists).then(processedChecklist => { | |
| const checklistData = processedChecklist.checklistData; | |
| const requirementNumberMapping = processedChecklist.requirementNumberMapping; | |
| this.state.setState({ | |
| checklistData: checklistData, | |
| requirementNumberMapping: requirementNumberMapping | |
| }); | |
| // Finalisation | |
| this.uiManager.showSuccess(`✅ Dossier chargé : ${companyName}`); | |
| this.uiManager.finalizeDataLoad(); | |
| }).catch(error => { | |
| console.error('Warning processing checklist:', error); | |
| this.uiManager.showError(`Attention : Le profil a été chargé, mais la checklist a rencontré une erreur : ${error.message}`); | |
| // On NE reset PAS car le profil est visible | |
| this.uiManager.showLoading(false); | |
| this.uiManager.showResults(true); | |
| }); | |
| } catch (error) { | |
| console.error('Critical Error in processAuditDataLogic:', error); | |
| this.uiManager.showError(`Erreur critique lors du chargement : ${error.message}`); | |
| this.uiManager.resetToUploadState(); | |
| } | |
| } | |
| extractCompanyProfile(food8) { | |
| if (!food8) return {}; | |
| const profile = {}; | |
| // Support de l'ancien appel (juste questions) ou du nouveau (objet complet) | |
| const questions = food8.questions || (food8.companyName ? null : food8); | |
| // Si food8 n'a pas de propriété 'questions' mais a des clés de questions, c'est peut-être déjà l'objet questions. | |
| // Mais avec mon refactoring, je passe food8 complet. Donc food8.questions devrait être la cible. | |
| const targetQuestions = food8.questions || {}; | |
| // 1. Extraction des QUESTIONS (metadata riche) | |
| const priorities = { | |
| 'companyName': 'Nom du site à auditer', | |
| 'companyCoid': 'N° COID du portail', | |
| // companyGln géré spécifiquement | |
| 'companyStreetNo': 'Rue', | |
| 'companyZip': 'Code postal', | |
| 'companyCity': 'Nom de la ville', | |
| 'companyCountry': 'Pays', | |
| 'companyTelephone': 'Téléphone', | |
| 'companyEmail': 'Email', | |
| 'companyGpsLatitude': 'Latitude', | |
| 'companyGpsLongitude': 'Longitude', | |
| 'headquartersName': 'Nom du siège social', | |
| 'headquartersStreetNo': 'Rue (siège social)', | |
| 'headquartersCity': 'Nom de la ville (siège social)', | |
| 'headquartersZip': 'Code postal (siège social)', | |
| 'headquartersCountry': 'Pays (siège social)', | |
| 'headquartersTelephone': 'Téléphone (siège social)', | |
| 'productionAreaSize': 'Surface couverte de l\'entreprise (m²)', | |
| 'numberOfBuildings': 'Nombre de bâtiments', | |
| 'numberOfProductionLines': 'Nombre de lignes de production', | |
| 'numberOfFloors': 'Nombre d\'étages', | |
| 'numberOfEmployeesForTimeCalculation': 'Nombre maximum d\'employés', | |
| 'numberOfEmployeesDescription': 'Commentaires employés', | |
| 'companyStructureDecentralisedDescription': 'Structures décentralisées', | |
| 'companyStructureMultiLocationProductionDescription': 'Fonctions centralisées', | |
| 'workingLanguage': 'Langue parlée et écrite sur le site', | |
| 'qmsLanguage': 'Langue du système qualité', | |
| 'scopeCertificateScopeDescription_en': 'Audit scope EN', | |
| 'scopeAuditScopeDescription': 'Périmètre de l\'audit FR', | |
| 'scopeProductGroupsDescription': 'Process et activités', | |
| 'seasonalProduction': 'Activité saisonnière ? (O/N)', | |
| 'partlyOutsourcedProcesses': 'Partie du procédé sous-traitée ?', | |
| 'partlyOutsourcedProcessesDescription': 'Procédés sous-traités', | |
| 'fullyOutsourcedProducts': 'Produits totalement sous-traités ?', | |
| 'fullyOutsourcedProductsDescription': 'Liste produits sous-traités', | |
| 'tradedProductsBrokerActivity': 'Produits de négoce ?', | |
| 'tradedProductsBrokerActivityDescription': 'Liste produits de négoce' | |
| }; | |
| // A. Traitement des QUESTIONS | |
| Object.entries(targetQuestions).forEach(([key, data]) => { | |
| if (!data || data.answer === undefined || data.answer === '' || data.answer === null) return; | |
| let label = priorities[key]; | |
| if (!label) { | |
| label = data.text || key; | |
| } | |
| let value = data.answer; | |
| // --- GESTION DES CHAMPS COMPLEXES SPÉCIFIQUES --- | |
| // 1. GLN (déjà traité) | |
| if (key === 'companyGln' && Array.isArray(value) && value[0]?.rootQuestions?.companyGlnNumber?.answer) { | |
| value = value[0].rootQuestions.companyGlnNumber.answer; | |
| } | |
| // 2. Langues (tableau simple) | |
| else if ((key === 'qmsLanguage' || key === 'workingLanguage' || key === 'auditLanguage') && Array.isArray(value)) { | |
| value = value.join(', '); | |
| } | |
| // 3. Codes Emballeur et Agrément Sanitaire (Tableaux d'objets imbriqués) | |
| else if ((key === 'companyLegalRegistrationNumbers' || key === 'companySanitaryLegalAuthorisationNumbers') && Array.isArray(value)) { | |
| // Structure: [{ rootQuestions: { keyText: { answer: "VALEUR" } } }] | |
| const codes = value.map(item => { | |
| const rootQ = item.rootQuestions; | |
| if (!rootQ) return null; | |
| // On cherche la clé qui contient "Text" ou "Number" | |
| // ex: companyLegalRegistrationNumbersText ou companySanitaryLegalAuthorisationNumbersText | |
| const textKey = Object.keys(rootQ).find(k => k.includes('Text') || k.includes('Number')); | |
| return textKey ? rootQ[textKey].answer : null; | |
| }).filter(Boolean).join(', '); | |
| if (codes) value = codes; | |
| else return; | |
| } | |
| // 4. Claims / Autres listes complexes similaires | |
| else if (key === 'companyClaimsOtherList' && Array.isArray(value)) { | |
| const claims = value.map(item => item.rootQuestions?.companyClaimsOtherListText?.answer).filter(Boolean).join(', '); | |
| if (claims) value = claims; | |
| else return; | |
| } | |
| // 5. Statuts Certification (Sous-traitance) - Tentative d'extraction si complexe | |
| else if ((key.includes('CertificationStatus') || key.includes('Coid')) && Array.isArray(value)) { | |
| // Souvent vide ou complexe. On tente le join si simple, sinon JSON stringify pour debug | |
| if (value.length > 0 && typeof value[0] !== 'object') value = value.join(', '); | |
| else if (value.length > 0) return; // On ignore pour l'instant si trop complexe | |
| else return; // Vide | |
| } | |
| // 6. Tableaux simples génériques | |
| else if (Array.isArray(value)) { | |
| if (value.length > 0 && typeof value[0] !== 'object') { | |
| value = value.join(', '); | |
| } else { return; } | |
| } | |
| // 7. Objets (on ignore sauf si géré plus haut) | |
| else if (typeof value === 'object') { | |
| return; | |
| } | |
| // Mappages de labels manquants spécifiques demandés | |
| if (key === 'companyLegalRegistrationNumbersDescription') label = 'Description Code Emballeur'; | |
| if (key === 'companyLegalRegistrationNumbers') label = 'Code Emballeur / Agrément'; // Sera écrasé ou ajouté | |
| if (key === 'companySanitaryLegalAuthorisationNumbers') label = 'N° Agrément Sanitaire'; | |
| if (key === 'fullyOutsourcedProducts') label = 'Produits totalement sous-traités ?'; | |
| if (key === 'tradedProductsBrokerActivity') label = 'Activité de négoce ?'; | |
| if (key === 'productsProducedProcessesRunning') label = 'Procédés observés (Audit)'; | |
| if (key === 'exclusions') label = 'Exclusions ?'; | |
| if (key === 'exclusionsDescription') label = 'Justification Exclusions'; | |
| if (key === 'followupSummary') label = 'Vérification des AC N-1 (Suivi)'; | |
| if (key === 'koSummary') label = 'Résumé KO'; | |
| if (value) profile[label] = value; | |
| }); | |
| // B. Extraction des propriétés RACINES utiles (hors checklists, questions, resultats, objets complexes traités après) | |
| const ignoredRoots = ['questions', 'checklists', 'matrixResult', 'result', 'users', 'auditDetails', 'originalFileName', 'auditors', 'participants', 'productScopesAudit', 'techScopesAudit', 'productScopesCertificate', 'techScopesCertificate', 'auditTimes', 'processingStepsAudit', 'processingStepsCertificate']; | |
| Object.keys(food8).forEach(key => { | |
| if (ignoredRoots.includes(key)) return; | |
| const val = food8[key]; | |
| if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') { | |
| const label = key.charAt(0).toUpperCase() + key.slice(1); | |
| profile[label] = val.toString(); | |
| } | |
| }); | |
| // C. Traitement des Objets Complexes Spécifiques (pour "tout extraire") | |
| // 1. Auditeurs | |
| if (food8.auditors) { | |
| const auditorList = Object.values(food8.auditors).map(a => { | |
| const data = a.auditorData || {}; | |
| return `${data.firstName || ''} ${data.lastName || ''} (${data.role || 'Auditor'})`.trim(); | |
| }).filter(s => s !== '()').join(', '); | |
| if (auditorList) profile['Auditeurs'] = auditorList; | |
| } | |
| // 2. Participants | |
| if (food8.participants) { | |
| const participantList = Object.values(food8.participants).map(p => { | |
| return `${p.firstName || ''} ${p.lastName || ''} - ${p.position || 'N/A'}`.trim(); | |
| }).filter(s => s !== '- N/A').join(', '); // Saut de ligne pour lisibilité si supporté par CSS, sinon virgule | |
| if (participantList) profile['Participants'] = participantList; | |
| // Note: Le CSS actuel du tableau supporte mal les \n, on verra. Sinon utiliser <br> si le rendu est HTML. | |
| // DataProcessor renderCompanyProfile utilise textContent ou innerHTML? Check renderCompanyProfile. | |
| // renderCompanyProfile dans data-processor.js utilise généralement textContent. | |
| // On va utiliser ", " pour l'instant. | |
| } | |
| // 3. Dates d'audit | |
| if (food8.auditTimes) { | |
| if (food8.auditTimes.startDate) profile['Date début audit'] = food8.auditTimes.startDate; | |
| if (food8.auditTimes.endDate) profile['Date fin audit'] = food8.auditTimes.endDate; | |
| if (food8.auditTimes.auditTimeInMinutes) profile['Durée audit (min)'] = food8.auditTimes.auditTimeInMinutes; | |
| } | |
| // 4. Scopes (Périmètres) | |
| const formatScopes = (scopes, labelPrefix) => { | |
| if (!scopes || !Array.isArray(scopes)) return; | |
| const scopeTexts = scopes.map(s => { | |
| if (s.text) return s.text; | |
| // Si pas de texte, on affiche le type ou UUID court | |
| return s.type === 'productScope' ? `Scope UUID: ...${s.uuid.slice(-6)}` : s.type; | |
| }).filter(Boolean).join(', '); | |
| if (scopeTexts) profile[labelPrefix] = scopeTexts; | |
| }; | |
| formatScopes(food8.productScopesAudit, 'Scopes Produits (Audit)'); | |
| formatScopes(food8.techScopesAudit, 'Scopes Techniques (Audit)'); | |
| console.log(`✅ ${Object.keys(profile).length} champs extraits (Questions + Root + Complexes)`); | |
| return profile; | |
| } | |
| async processChecklistData(checklists) { | |
| if (!checklists?.checklistFood8?.resultScorings) { | |
| return { checklistData: [], requirementNumberMapping: {} }; | |
| } | |
| const resultScorings = checklists.checklistFood8.resultScorings; | |
| console.log('🎯 MAPPING UUID → NUMÉROS IFS OFFICIELS'); | |
| console.log(`📊 ${Object.keys(resultScorings).length} UUIDs détectés`); | |
| try { | |
| const response = await fetch('https://raw.githubusercontent.com/M00N69/Gemini-Knowledge/refs/heads/main/IFSV8listUUID.csv'); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const csvText = await response.text(); | |
| console.log('✅ CSV chargé depuis GitHub'); | |
| const uuidToInfo = this.validateAndProcessCSV(csvText); | |
| const ifsUUIDs = Object.keys(resultScorings); | |
| const matchedUUIDs = ifsUUIDs.filter(uuid => uuidToInfo[uuid]); | |
| const unmatchedUUIDs = ifsUUIDs.filter(uuid => !uuidToInfo[uuid]); | |
| console.log(`✅ UUIDs MATCHÉS : ${matchedUUIDs.length}/${ifsUUIDs.length} (${((matchedUUIDs.length / ifsUUIDs.length) * 100).toFixed(1)}%)`); | |
| if (unmatchedUUIDs.length > 0) { | |
| console.error(`❌ ${unmatchedUUIDs.length} UUID(s) non mappé(s)`); | |
| const errorMessage = `❌ MAPPING INCOMPLET : ${unmatchedUUIDs.length} exigence(s) non mappée(s) sur ${ifsUUIDs.length}`; | |
| this.uiManager.showError(errorMessage); | |
| this.uiManager.resetToUploadState(); | |
| return { checklistData: [], requirementNumberMapping: {} }; | |
| } | |
| console.log(`🎯 MAPPING RÉUSSI À 100% : ${matchedUUIDs.length}/${ifsUUIDs.length} exigences`); | |
| const tempData = []; | |
| for (const uuid in resultScorings) { | |
| const scoring = resultScorings[uuid]; | |
| const mappedInfo = uuidToInfo[uuid]; | |
| tempData.push({ | |
| uuid, | |
| requirementNumber: mappedInfo.num, | |
| chapter: mappedInfo.chapitre, | |
| theme: mappedInfo.theme, | |
| sstheme: mappedInfo.sstheme, | |
| scoring | |
| }); | |
| } | |
| tempData.sort((a, b) => { | |
| if (a.chapter !== b.chapter) return a.chapter - b.chapter; | |
| const aParts = a.requirementNumber.split('.').map(n => parseInt(n) || 0); | |
| const bParts = b.requirementNumber.split('.').map(n => parseInt(n) || 0); | |
| for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { | |
| const aVal = aParts[i] || 0; | |
| const bVal = bParts[i] || 0; | |
| if (aVal !== bVal) return aVal - bVal; | |
| } | |
| return 0; | |
| }); | |
| let newChecklistData = []; | |
| let newRequirementNumberMapping = {}; | |
| tempData.forEach(item => { | |
| newRequirementNumberMapping[item.uuid] = item.requirementNumber; | |
| let fieldAnswersText = ''; | |
| if (item.scoring.answers?.fieldAnswers) { | |
| Object.entries(item.scoring.answers.fieldAnswers).forEach(([k, v]) => { | |
| if (v?.answer && v.answer !== 'false' && v.answer !== 'true' && v.answer !== '') { | |
| // Clean up key for display if possible, or just use answer | |
| // If key ends in _en, maybe ignore if we have _fr or base? | |
| // For now, simpler: Just show the answer. | |
| // Often these are additional questions. | |
| fieldAnswersText += `\n> ${v.answer}`; | |
| } | |
| }); | |
| } | |
| newChecklistData.push({ | |
| uuid: item.uuid, | |
| requirementNumber: item.requirementNumber, | |
| chapter: item.chapter, | |
| theme: item.theme, | |
| sstheme: item.sstheme, | |
| score: item.scoring.score?.label || 'N/D', | |
| scoreValue: item.scoring.score?.value, | |
| explanation: item.scoring.answers?.explanationText || '', | |
| detailedExplanation: (item.scoring.answers?.englishExplanationText || '') + fieldAnswersText, | |
| correction: item.scoring.answers?.correctionText || '', | |
| correctiveAction: item.scoring.answers?.correctiveActionText || '', | |
| needsCorrection: item.scoring.isCorrectionRequired || false | |
| }); | |
| }); | |
| console.log(`✅ ${newChecklistData.length} exigences traitées avec mapping UUID complet`); | |
| return { checklistData: newChecklistData, requirementNumberMapping: newRequirementNumberMapping }; | |
| } catch (error) { | |
| console.error('❌ ERREUR CRITIQUE lors du chargement du CSV de mapping:', error); | |
| const errorMessage = `❌ ERREUR DE CHARGEMENT CSV\n\nImpossible de charger le CSV de mapping des exigences IFS.`; | |
| this.uiManager.showError(errorMessage); | |
| this.uiManager.resetToUploadState(); | |
| return { checklistData: [], requirementNumberMapping: {} }; | |
| } | |
| } | |
| validateAndProcessCSV(csvText) { | |
| const rows = this.parseCSVWithMultilineSupport(csvText); | |
| if (rows.length < 2) { | |
| throw new Error('CSV doit contenir au moins un header et une ligne de données'); | |
| } | |
| const headers = rows[0].map(h => h.replace(/^["']|["']$/g, '').trim()); | |
| const requiredColumns = ['UUID', 'Num', 'Chapitre', 'Theme', 'SSTheme']; | |
| const missingColumns = requiredColumns.filter(col => !headers.includes(col)); | |
| if (missingColumns.length > 0) { | |
| throw new Error(`Colonnes requises manquantes dans le CSV: ${missingColumns.join(', ')}`); | |
| } | |
| const columnIndices = { | |
| uuid: headers.indexOf('UUID'), | |
| num: headers.indexOf('Num'), | |
| chapitre: headers.indexOf('Chapitre'), | |
| theme: headers.indexOf('Theme'), | |
| sstheme: headers.indexOf('SSTheme') | |
| }; | |
| const validMappings = {}; | |
| for (let i = 1; i < rows.length; i++) { | |
| const cols = rows[i]; | |
| if (!cols || cols.length === 0 || cols.every(col => !col.trim())) continue; | |
| const uuid = cols[columnIndices.uuid]?.trim(); | |
| let num = cols[columnIndices.num]?.trim(); | |
| const chapitre = cols[columnIndices.chapitre]?.trim(); | |
| const theme = cols[columnIndices.theme]?.trim(); | |
| const sstheme = cols[columnIndices.sstheme]?.trim(); | |
| if (!uuid || uuid.length < 10 || !num) continue; | |
| num = num.replace(/[\r\n]/g, ' ') | |
| .replace(/\s*(KO|NEW|\*)\s*/gi, '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| if (!num) continue; | |
| let chapitreNum; | |
| if (chapitre && chapitre.includes('-')) { | |
| chapitreNum = parseInt(chapitre.split('-')[0]); | |
| } else { | |
| chapitreNum = parseInt(chapitre); | |
| } | |
| if (isNaN(chapitreNum) || chapitreNum < 1 || chapitreNum > 5) continue; | |
| if (validMappings[uuid]) continue; | |
| validMappings[uuid] = { | |
| num: num, | |
| chapitre: chapitreNum, | |
| theme: theme || 'N/A', | |
| sstheme: sstheme || 'N/A' | |
| }; | |
| } | |
| return validMappings; | |
| } | |
| parseCSVWithMultilineSupport(csvText) { | |
| const lines = csvText.split('\n'); | |
| const result = []; | |
| let currentRow = []; | |
| let currentField = ''; | |
| let inQuotes = false; | |
| let i = 0; | |
| while (i < lines.length) { | |
| const line = lines[i]; | |
| let j = 0; | |
| while (j < line.length) { | |
| const char = line[j]; | |
| const nextChar = line[j + 1]; | |
| if (char === '"') { | |
| if (inQuotes && nextChar === '"') { | |
| currentField += '"'; | |
| j += 2; | |
| continue; | |
| } | |
| inQuotes = !inQuotes; | |
| } else if (char === ',' && !inQuotes) { | |
| currentRow.push(currentField.trim()); | |
| currentField = ''; | |
| } else { | |
| currentField += char; | |
| } | |
| j++; | |
| } | |
| if (inQuotes) { | |
| currentField += '\n'; | |
| } else { | |
| currentRow.push(currentField.trim()); | |
| result.push(currentRow); | |
| currentRow = []; | |
| currentField = ''; | |
| } | |
| i++; | |
| } | |
| if (currentRow.length > 0 || currentField) { | |
| currentRow.push(currentField.trim()); | |
| result.push(currentRow); | |
| } | |
| return result; | |
| } | |
| renderCompanyProfile() { | |
| const container = document.getElementById('companyProfileTable'); | |
| if (!container) return; | |
| const companyProfileData = this.state.get().companyProfileData; | |
| const conversations = this.state.get().conversations; | |
| if (!companyProfileData || Object.keys(companyProfileData).length === 0) { | |
| container.innerHTML = '<p class="text-center p-10 text-gray-500">Aucune donnée de profil. Chargez un fichier.</p>'; | |
| return; | |
| } | |
| const categories = { | |
| 'Informations générales': ['Nom du site à auditer', 'N° COID du portail', 'Code GLN', 'Auditeurs', 'Participants', 'Date début audit', 'Date fin audit', 'Durée audit (min)', 'Code Emballeur / Agrément', 'N° Agrément Sanitaire'], | |
| 'Adresse du site': ['Rue', 'Code postal', 'Nom de la ville', 'Pays', 'Téléphone', 'Email', 'Latitude', 'Longitude'], | |
| 'Siège social': ['Nom du siège social', 'Rue (siège social)', 'Nom de la ville (siège social)', 'Code postal (siège social)', 'Pays (siège social)', 'Téléphone (siège social)'], | |
| 'Informations techniques': ['Surface couverte de l\'entreprise (m²)', 'Nombre de bâtiments', 'Nombre de lignes de production', 'Nombre d\'étages', 'Nombre maximum d\'employés', 'Commentaires employés'], | |
| 'Structure organisationnelle': ['Structures décentralisées', 'Fonctions centralisées'], | |
| 'Langues': ['Langue parlée et écrite sur le site', 'Langue du système qualité', 'Langue audit'], | |
| 'Périmètres d\'audit': ['Audit scope EN', 'Scopes Produits (Audit)', 'Scopes Techniques (Audit)', 'Périmètre de l\'audit FR', 'Process et activités'], | |
| 'Activités spécifiques': [ | |
| 'Activité saisonnière ? (O/N)', | |
| 'Partie du procédé sous-traitée ?', 'Procédés sous-traités', | |
| 'Produits totalement sous-traités ?', 'Liste produits sous-traités', | |
| 'Activité de négoce ?', 'Liste produits de négoce', | |
| 'Procédés observés (Audit)' | |
| ], | |
| 'Exclusions': ['Exclusions ?', 'Justification Exclusions'], | |
| 'Résultats et Suivi': ['Résumé KO', 'Vérification des AC N-1 (Suivi)'] | |
| }; | |
| let html = ''; | |
| const displayedFields = new Set(); | |
| const renderTableSection = (title, fields) => { | |
| const visibleFields = fields.filter(f => companyProfileData.hasOwnProperty(f)); | |
| if (visibleFields.length === 0) return ''; | |
| let sectionHtml = `<div class="category-header">${title}</div> | |
| <div class="table-container"> | |
| <table class="data-table"> | |
| <thead><tr><th>Information</th><th>Valeur</th><th>Commentaires</th></tr></thead> | |
| <tbody>`; | |
| visibleFields.forEach(field => { | |
| displayedFields.add(field); | |
| const value = companyProfileData[field]; | |
| const fieldId = `profile-${this.sanitizeFieldId(field)}`; | |
| const conversation = conversations[fieldId]; | |
| const commentStatus = this.getConversationStatus(conversation); | |
| const commentCount = conversation?.thread?.length || 0; | |
| const isLongText = (typeof value === 'string' && value.length > 100) || ['Périmètre de l\'audit FR', 'Audit scope EN', 'Process et activités', 'Procédés observés (Audit)', 'Vérification des AC N-1 (Suivi)'].includes(field); | |
| const displayValue = isLongText ? | |
| `<div class="field-display long-text">${value || 'N/A'}</div>` : | |
| `<span>${value || 'N/A'}</span>`; | |
| sectionHtml += `<tr class="table-row-clickable" data-field-id="${fieldId}" data-comment-status="${commentStatus}" onclick="openCommentModal(this)"> | |
| <td class="font-medium" style="width: 30%;">${field}</td> | |
| <td style="width: 60%;">${displayValue}</td> | |
| <td class="comment-status-cell" style="width: 10%;"> | |
| <div class="comment-indicators"> | |
| <span class="comment-count-badge" style="display: ${commentCount > 0 ? 'inline-flex' : 'none'}">${commentCount}</span> | |
| <span class="status-indicator ${commentStatus}"></span> | |
| <button class="quick-comment-btn" onclick="event.stopPropagation(); openCommentModal(this.closest('tr'))"> | |
| <i class="fas fa-comment"></i> | |
| </button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }); | |
| sectionHtml += `</tbody></table></div>`; | |
| return sectionHtml; | |
| }; | |
| // 1. Rendu des catégories définies | |
| Object.entries(categories).forEach(([categoryName, fields]) => { | |
| html += renderTableSection(categoryName, fields); | |
| }); | |
| // 2. Rendu des champs restants (Catch-All) | |
| const allKeys = Object.keys(companyProfileData); | |
| const remainingKeys = allKeys.filter(k => !displayedFields.has(k)).sort(); | |
| if (remainingKeys.length > 0) { | |
| html += renderTableSection('Autres Informations / Méta-données', remainingKeys); | |
| } | |
| container.innerHTML = html; | |
| if (this.filterProfileTable) this.filterProfileTable(); // Re-apply existing filters if any | |
| } | |
| renderChecklistTable() { | |
| const tbody = document.getElementById('checklistTableBody'); | |
| if (!tbody) return; | |
| // Use the new filtered data source instead of the raw one | |
| const checklistData = this.state.getFilteredChecklistData(); | |
| const conversations = this.state.get().conversations; | |
| if (!checklistData || checklistData.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="text-center p-10">Aucun élément ne correspond au filtre actif.</td></tr>'; | |
| return; | |
| } | |
| let html = ''; | |
| checklistData.forEach(item => { | |
| const isNC = ['B', 'C', 'D'].includes(item.score); | |
| const constatFieldId = `ckl-${item.uuid}`; | |
| const paFieldId = `pa-${item.uuid}`; | |
| const constatConv = conversations[constatFieldId] || conversations[`req-${item.uuid}`]; // Fallback old format | |
| const paConv = conversations[paFieldId]; | |
| const constatStatus = this.getConversationStatus(constatConv); | |
| const constatCount = constatConv?.thread?.length || 0; | |
| const paStatus = this.getConversationStatus(paConv); | |
| const paCount = paConv?.thread?.length || 0; | |
| html += `<tr class="table-row-clickable" data-chapter="${item.chapter}" data-score="${item.score}"> | |
| <td class="font-medium">${item.requirementNumber}</td> | |
| <td><span class="score-badge score-${item.score}">${item.score}</span></td> | |
| <td class="max-w-xs text-xs">${item.explanation || ''}</td> | |
| <td class="max-w-xs text-xs">${item.detailedExplanation || ''}</td> | |
| <td class="comment-status-cell" onclick="event.stopPropagation(); window.openCommentModalFromCell(this, '${constatFieldId}')"> | |
| <div class="comment-indicators"> | |
| <span class="comment-count-badge" style="display: ${constatCount > 0 ? 'inline-flex' : 'none'}">${constatCount}</span> | |
| <span class="status-indicator ${constatStatus}"></span> | |
| <button class="quick-comment-btn"> | |
| <i class="fas fa-comment"></i> | |
| </button> | |
| </div> | |
| </td> | |
| <td class="comment-status-cell ${isNC ? '' : 'opacity-20 pointer-events-none'}" onclick="event.stopPropagation(); if(${isNC}) window.openCommentModalFromCell(this, '${paFieldId}')"> | |
| <div class="comment-indicators"> | |
| <span class="comment-count-badge" style="display: ${paCount > 0 ? 'inline-flex' : 'none'}">${paCount}</span> | |
| <span class="status-indicator ${paStatus}"></span> | |
| <button class="quick-comment-btn"> | |
| <i class="fas fa-tools"></i> | |
| </button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }); | |
| tbody.innerHTML = html; | |
| this.setupTableFilters(); | |
| } | |
| renderNonConformitiesTable() { | |
| const tbody = document.getElementById('nonConformitiesTableBody'); | |
| if (!tbody) return; | |
| const checklistData = this.state.get().checklistData; | |
| const conversations = this.state.get().conversations; | |
| if (!checklistData || checklistData.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="text-center p-10">Aucune donnée de NC/NA.</td></tr>'; | |
| return; | |
| } | |
| const nonConformItems = checklistData.filter(item => ['B', 'C', 'D', 'NA'].includes(item.score)); | |
| if (nonConformItems.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="5" class="text-center p-10 text-green-500">🎉 Aucune NC/NA trouvée !</td></tr>'; | |
| return; | |
| } | |
| let html = ''; | |
| nonConformItems.forEach(item => { | |
| const fieldId = `req-${item.uuid}`; | |
| const conversation = conversations[fieldId]; | |
| const commentStatus = this.getConversationStatus(conversation); | |
| const commentCount = conversation?.thread?.length || 0; | |
| html += `<tr class="table-row-clickable" data-field-id="${fieldId}" data-chapter="${item.chapter}" data-score="${item.score}" data-comment-status="${commentStatus}" onclick="openCommentModal(this)"> | |
| <td class="font-medium">${item.requirementNumber}</td> | |
| <td><span class="score-badge score-${item.score}">${item.score}</span></td> | |
| <td class="whitespace-pre-wrap max-w-xs">${item.explanation || '-'}</td> | |
| <td class="whitespace-pre-wrap max-w-xs">${item.detailedExplanation || '-'}</td> | |
| <td class="comment-status-cell"> | |
| <div class="comment-indicators"> | |
| <span class="comment-count-badge" style="display: ${commentCount > 0 ? 'inline-flex' : 'none'}">${commentCount}</span> | |
| <span class="status-indicator ${commentStatus}"></span> | |
| <button class="quick-comment-btn" onclick="event.stopPropagation(); openCommentModal(this.closest('tr'))"> | |
| <i class="fas fa-comment"></i> | |
| </button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }); | |
| tbody.innerHTML = html; | |
| this.updateNonConformitiesStats(); | |
| } | |
| renderAuditorTaskList() { | |
| const tbody = document.getElementById('auditorTasksBody'); | |
| const emptyState = document.getElementById('auditorEmptyState'); | |
| const tableContainer = document.getElementById('auditorTasksContainer'); | |
| if (!tbody) return; | |
| const conversations = this.state.get().conversations; | |
| // Filter logic | |
| const filter = this.state.get().activeFilters.auditorTasks?.filter || 'pending'; | |
| // Update Buttons | |
| ['pending', 'resolved', 'all'].forEach(f => { | |
| const btn = document.getElementById(`auditor-filter-${f}`); | |
| if (btn) { | |
| if (f === filter) { | |
| btn.classList.add('btn-primary'); | |
| btn.classList.remove('btn-secondary'); | |
| } else { | |
| btn.classList.add('btn-secondary'); | |
| btn.classList.remove('btn-primary'); | |
| } | |
| } | |
| }); | |
| let tasks = []; | |
| let pendingCount = 0; | |
| let resolvedCount = 0; | |
| Object.entries(conversations).forEach(([fieldId, conversation]) => { | |
| const thread = conversation.thread; | |
| if (!thread || thread.length === 0) return; | |
| const lastMessage = thread[thread.length - 1]; | |
| const isResolved = conversation.status === 'resolved'; | |
| // "Pending": Reviewer spoke last, and not resolved. Auditor needs to act. | |
| const isPending = !isResolved && lastMessage.author === 'reviewer'; | |
| // "Replied/Resolved": Auditor spoke last (waiting for reviewer) OR explicitly resolved | |
| const isReplied = !isResolved && lastMessage.author === 'auditor'; | |
| if (isPending) pendingCount++; | |
| if (isResolved || isReplied) resolvedCount++; | |
| let include = false; | |
| if (filter === 'pending' && isPending) include = true; | |
| if (filter === 'resolved' && (isResolved || isReplied)) include = true; | |
| if (filter === 'all' && (isPending || isResolved || isReplied)) include = true; | |
| if (include) { | |
| const fieldInfo = this.getFieldInfo(fieldId); | |
| let statusLabel = ''; | |
| let statusClass = ''; | |
| if (isPending) { | |
| statusLabel = 'À traiter'; | |
| statusClass = 'pending'; | |
| } else if (isReplied) { | |
| statusLabel = 'En attente Reviewer'; | |
| statusClass = 'read'; // Reuse 'read' style for simplicity or add custom | |
| } else if (isResolved) { | |
| statusLabel = 'Résolu / Clôturé'; | |
| statusClass = 'resolved'; | |
| } | |
| tasks.push({ | |
| fieldId: fieldId, | |
| type: fieldInfo.type, | |
| name: fieldInfo.name, | |
| lastMessage: lastMessage.content, | |
| date: lastMessage.date, | |
| statusLabel, | |
| statusClass, | |
| isResolved: isResolved || isReplied | |
| }); | |
| } | |
| }); | |
| // Update Counters (Independent of view filter) | |
| this.uiManager.updateElementText('auditorTodoCounter', pendingCount > 0 ? pendingCount : ''); | |
| this.uiManager.updateElementText('auditorPendingTotal', pendingCount); | |
| this.uiManager.updateElementText('auditorResolvedTotal', resolvedCount); | |
| // Sort Tasks | |
| tasks.sort((a, b) => { | |
| // Prioritize Pending items at the top | |
| if (a.statusClass === 'pending' && b.statusClass !== 'pending') return -1; | |
| if (a.statusClass !== 'pending' && b.statusClass === 'pending') return 1; | |
| if (a.statusClass === 'pending') { | |
| // Pending: Oldest first (to handle queue) | |
| return new Date(a.date) - new Date(b.date); | |
| } else { | |
| // Resolved/Replied: Newest first (history) | |
| return new Date(b.date) - new Date(a.date); | |
| } | |
| }); | |
| // Display Logic | |
| if (tasks.length === 0) { | |
| if (tableContainer) tableContainer.classList.add('hidden'); | |
| if (emptyState) { | |
| // Only show the "Good Job" empty state if we are filtering for PENDING and there are no pending tasks. | |
| if (filter === 'pending') { | |
| emptyState.classList.remove('hidden'); | |
| emptyState.innerHTML = ` | |
| <div class="text-green-500 text-5xl mb-4"><i class="fas fa-check-circle"></i></div> | |
| <h3 class="text-xl font-bold text-gray-700 dark:text-gray-200">Tout est à jour !</h3> | |
| <p class="text-gray-500 mt-2">Vous avez répondu à toutes les questions du reviewer.</p> | |
| <button onclick="showPackageModal()" class="btn btn-primary mt-6"> | |
| <i class="fas fa-reply"></i> Renvoyer le package au Reviewer | |
| </button> | |
| `; | |
| } else { | |
| emptyState.classList.remove('hidden'); | |
| emptyState.innerHTML = ` | |
| <div class="text-gray-400 text-5xl mb-4"><i class="fas fa-inbox"></i></div> | |
| <h3 class="text-xl font-bold text-gray-700 dark:text-gray-200">Aucun élément</h3> | |
| <p class="text-gray-500 mt-2">Aucune conversation ne correspond à ce filtre.</p> | |
| `; | |
| } | |
| } | |
| } else { | |
| if (tableContainer) tableContainer.classList.remove('hidden'); | |
| if (emptyState) emptyState.classList.add('hidden'); | |
| let html = ''; | |
| tasks.forEach(task => { | |
| let shortMsg = task.lastMessage.length > 80 ? task.lastMessage.substring(0, 80) + '...' : task.lastMessage; | |
| let typeLabel = ''; | |
| if (task.type === 'profile') { | |
| typeLabel = '<span class="score-badge" style="background-color:#3b82f6">Profil</span>'; | |
| } else if (task.type === 'nc-pa' || task.fieldId.startsWith('pa-')) { | |
| typeLabel = '<span class="score-badge" style="background-color:#ef4444">Plan d\'actions</span>'; | |
| } else if (task.type === 'ckl' || task.fieldId.startsWith('ckl-')) { | |
| typeLabel = '<span class="score-badge" style="background-color:#f59e0b">Constat d\'audit</span>'; | |
| } else { | |
| typeLabel = '<span class="score-badge" style="background-color:#8b5cf6">Checklist</span>'; | |
| } | |
| html += ` | |
| <tr class="table-row-clickable" onclick="openCommentModal(this)" data-field-id="${task.fieldId}"> | |
| <td>${typeLabel}</td> | |
| <td class="font-bold">${task.name}</td> | |
| <td class="text-gray-600 dark:text-gray-400"><i class="fas fa-quote-left text-xs opacity-50"></i> ${shortMsg}</td> | |
| <td><span class="status-badge ${task.statusClass}">${task.statusLabel}</span></td> | |
| <td> | |
| <button class="btn btn-sm btn-primary"> | |
| <i class="fas fa-${task.statusClass === 'pending' ? 'reply' : 'eye'}"></i> ${task.statusClass === 'pending' ? 'Répondre' : 'Voir'} | |
| </button> | |
| </td> | |
| </tr> | |
| `; | |
| }); | |
| tbody.innerHTML = html; | |
| } | |
| } | |
| getFieldInfo(fieldId) { | |
| const companyProfileData = this.state.get().companyProfileData; | |
| const checklistData = this.state.get().checklistData; | |
| if (fieldId.startsWith('profile-')) { | |
| const sanitizedPart = fieldId.replace('profile-', ''); | |
| let originalFieldName = ''; | |
| for (const fieldName in companyProfileData) { | |
| if (this.sanitizeFieldId(fieldName) === sanitizedPart) { | |
| originalFieldName = fieldName; | |
| break; | |
| } | |
| } | |
| if (originalFieldName) { | |
| return { | |
| name: originalFieldName, | |
| content: companyProfileData[originalFieldName] || 'N/A', | |
| type: 'profile' | |
| }; | |
| } | |
| } else if (fieldId.startsWith('dossier-')) { | |
| const rawId = fieldId.replace('dossier-', ''); | |
| let name = 'Document du dossier'; | |
| let description = ''; | |
| // Search in the static structure | |
| for (const key in DataProcessor.REVIEW_CHECKLIST_STRUCTURE) { | |
| const category = DataProcessor.REVIEW_CHECKLIST_STRUCTURE[key]; | |
| const item = category.items.find(i => i.id === rawId); | |
| if (item) { | |
| name = item.nom; | |
| description = item.description || ''; | |
| break; | |
| } | |
| } | |
| let contentHTML = `<strong>${name}</strong><br>`; | |
| if (description) { | |
| contentHTML += `<p class="text-sm text-gray-500 mb-2">${description}</p>`; | |
| } | |
| contentHTML += `<br>Point de contrôle de la revue du dossier.`; | |
| return { | |
| name: `[DOSSIER] ${name}`, | |
| content: contentHTML, | |
| type: 'dossier' | |
| }; | |
| } else if (fieldId.startsWith('ckl-') || fieldId.startsWith('pa-') || fieldId.startsWith('req-') || fieldId.startsWith('nc-')) { | |
| const prefix = fieldId.startsWith('ckl-') ? 'ckl-' : (fieldId.startsWith('pa-') ? 'pa-' : (fieldId.startsWith('req-') ? 'req-' : 'nc-')); | |
| const uuid = fieldId.replace(prefix, ''); | |
| const item = checklistData.find(r => r.uuid === uuid); | |
| if (item) { | |
| let contentHTML = `<strong>Exigence ${item.requirementNumber}</strong> - `; | |
| contentHTML += `<span class="score-badge score-${item.score}">${item.score}</span><br><br>`; | |
| if (prefix === 'pa-') { | |
| contentHTML += `<div style="border-left: 4px solid var(--color-danger); padding-left: 10px; margin-bottom: 15px;"> | |
| <h4 style="margin: 0 0 10px 0; color: var(--color-danger);">PLAN D'ACTIONS</h4>`; | |
| contentHTML += `<strong>Correction (Action Immédiate):</strong><br><div class="p-2 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded mb-2">${item.correction || '<span class="text-gray-400 italic">Non renseigné</span>'}</div>`; | |
| contentHTML += `<strong>Preuves de correction:</strong><br><div class="p-2 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded mb-2">${item.evidence || '<span class="text-gray-400 italic">Non renseigné</span>'}</div>`; | |
| contentHTML += `<strong>Action Corrective (Plan d'action):</strong><br><div class="p-2 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded mb-2">${item.correctiveAction || '<span class="text-gray-400 italic">Non renseigné</span>'}</div>`; | |
| contentHTML += `</div>`; | |
| return { | |
| name: `[P.A.] Exigence ${item.requirementNumber}`, | |
| content: contentHTML, | |
| type: 'nc-pa' | |
| }; | |
| } else { | |
| // CKL or REQ or NC fallback -> Constat focus | |
| contentHTML += `<div style="border-left: 4px solid var(--color-theme-500); padding-left: 10px;"> | |
| <h4 style="margin: 0 0 10px 0; color: var(--color-theme-600);">CONSTAT D'AUDIT</h4>`; | |
| contentHTML += `<strong>Constat (Explanation):</strong><br><div class="p-2 bg-gray-50 dark:bg-gray-800 rounded mb-2">${item.explanation || '-'}</div>`; | |
| contentHTML += `<strong>Explication détaillée:</strong><br><div class="p-2 bg-gray-50 dark:bg-gray-800 rounded mb-2">${item.detailedExplanation || '-'}</div>`; | |
| contentHTML += `</div>`; | |
| return { | |
| name: `[CONSTAT] Exigence ${item.requirementNumber}`, | |
| content: contentHTML, | |
| type: prefix === 'ckl-' ? 'ckl' : 'requirement' | |
| }; | |
| } | |
| } | |
| } | |
| return { | |
| name: 'Champ inconnu', | |
| content: 'Contenu non disponible', | |
| type: 'unknown' | |
| }; | |
| } | |
| addCommentToConversation(fieldId, comment) { | |
| const currentConversations = this.state.get().conversations; | |
| const oldConversation = currentConversations[fieldId]; | |
| // Create a new thread with the new comment and update statuses immutably. | |
| const newThreadWithComment = oldConversation ? [...oldConversation.thread, comment] : [comment]; | |
| const updatedThread = newThreadWithComment.map((msg, index) => { | |
| if (index < newThreadWithComment.length - 1 && msg.status === 'pending') { | |
| return { ...msg, status: 'read' }; | |
| } | |
| return msg; | |
| }); | |
| // Create new history array, safely handling cases where history might not exist. | |
| const newHistory = oldConversation?.history ? [...oldConversation.history] : []; | |
| if (!oldConversation) { | |
| newHistory.push({ | |
| type: 'conversation_created', | |
| date: new Date().toISOString(), | |
| actor: comment.author, | |
| details: 'Conversation initiée' | |
| }); | |
| } | |
| newHistory.push({ | |
| type: 'comment_added', | |
| date: comment.date, | |
| actor: comment.author, | |
| details: `Commentaire ajouté par ${comment.author}` | |
| }); | |
| const newConversation = { | |
| ...(oldConversation || {}), | |
| thread: updatedThread, | |
| lastActivity: comment.date, | |
| status: oldConversation?.status === 'resolved' ? 'active' : (oldConversation?.status || 'active'), | |
| priority: oldConversation?.priority || 'normal', | |
| history: newHistory | |
| }; | |
| const newConversations = { | |
| ...currentConversations, | |
| [fieldId]: newConversation | |
| }; | |
| this.state.setState({ conversations: newConversations, hasUnsavedChanges: true }); | |
| } | |
| getConversationStatus(conversation) { | |
| if (!conversation || !conversation.thread || conversation.thread.length === 0) { | |
| return 'none'; | |
| } | |
| if (conversation.status === 'resolved') { | |
| return 'resolved'; | |
| } | |
| const visibleMessages = conversation.thread.filter(m => !m.isDeleted); | |
| if (visibleMessages.length === 0) { | |
| return 'none'; | |
| } | |
| const lastMessage = visibleMessages[visibleMessages.length - 1]; | |
| const currentUser = this.state.get().currentMode; | |
| if (lastMessage.author !== currentUser) { | |
| return 'pending'; | |
| } | |
| return 'read'; | |
| } | |
| getStatusLabel(status) { | |
| const labels = { | |
| 'pending': 'En attente', | |
| 'read': 'Lu', | |
| 'resolved': 'Résolu', | |
| 'none': 'Aucun' | |
| }; | |
| return labels[status] || status; | |
| } | |
| addHistoryEntry(fieldId, type, actor, details) { | |
| const conversations = { ...this.state.get().conversations }; | |
| if (conversations[fieldId]) { | |
| conversations[fieldId].history.push({ | |
| type, | |
| date: new Date().toISOString(), | |
| actor, | |
| details | |
| }); | |
| conversations[fieldId].lastActivity = new Date().toISOString(); | |
| this.state.setState({ conversations, hasUnsavedChanges: true }); | |
| } | |
| } | |
| getCommentsText(fieldId) { | |
| const conversation = this.state.get().conversations[fieldId]; | |
| if (!conversation || !conversation.thread.length) return '-'; | |
| return conversation.thread.map(comment => | |
| `[${comment.author}] ${comment.content}` | |
| ).join(' | '); | |
| } | |
| refreshAllCounters() { | |
| this.refreshCountersForTab('profil'); | |
| this.refreshCountersForTab('checklist'); | |
| this.refreshCountersForTab('nonconformites'); | |
| // Also refresh auditor tasks if relevant | |
| if (this.state.get().currentMode === 'auditor') { | |
| this.renderAuditorTaskList(); | |
| } | |
| this.updateProgressStats(); | |
| } | |
| refreshCountersForTab(tabId) { | |
| console.log(`--- Refreshing counters for tab: ${tabId} ---`); | |
| // Skip for auditor tasks tab as it's handled separately | |
| if (tabId === 'auditor-tasks') return; | |
| let pending = 0, resolved = 0, total = 0; | |
| const conversations = this.state.get().conversations; | |
| const checklistData = this.state.get().checklistData; | |
| const companyProfileData = this.state.get().companyProfileData; | |
| if (tabId === 'profil') { | |
| Object.keys(companyProfileData).forEach(field => { | |
| const fieldId = `profile-${this.sanitizeFieldId(field)}`; | |
| const conversation = conversations[fieldId]; | |
| if (conversation && conversation.thread?.length > 0) { | |
| total++; | |
| const status = this.getConversationStatus(conversation); | |
| if (status === 'resolved') resolved++; | |
| else if (status === 'pending') pending++; | |
| } | |
| }); | |
| } else if (tabId === 'checklist') { | |
| checklistData.forEach(item => { | |
| const fieldId = `req-${item.uuid}`; | |
| const conversation = conversations[fieldId]; | |
| if (conversation && conversation.thread?.length > 0) { | |
| total++; | |
| const status = this.getConversationStatus(conversation); | |
| if (status === 'resolved') resolved++; | |
| else if (status === 'pending') pending++; | |
| } | |
| }); | |
| } else if (tabId === 'nonconformites') { | |
| const nonConformItems = checklistData.filter(item => ['B', 'C', 'D', 'NA'].includes(item.score)); | |
| nonConformItems.forEach((item, index) => { | |
| const fieldId = `req-${item.uuid}`; | |
| const conversation = conversations[fieldId]; | |
| if (conversation && conversation.thread?.length > 0) { | |
| total++; | |
| const status = this.getConversationStatus(conversation); | |
| if (status === 'resolved') resolved++; | |
| else if (status === 'pending') pending++; | |
| } | |
| }); | |
| } else if (tabId === 'dossier') { | |
| Object.values(DataProcessor.REVIEW_CHECKLIST_STRUCTURE).forEach(cat => { | |
| cat.items.forEach(item => { | |
| total++; | |
| const status = this.state.get().dossierReviewState?.[item.id]; | |
| const conversation = conversations[`dossier-${item.id}`]; | |
| // Total counts conversations (consistent with other tabs) | |
| // But for Dossier, we often want to know what's left to process | |
| const hasComment = conversation && conversation.thread?.length > 0; | |
| if (!status) pending++; | |
| // Note: resolved is tricky here as it's about comments. | |
| // Let's stick to comment status for resolved/pending logic | |
| if (hasComment) { | |
| const convStatus = this.getConversationStatus(conversation); | |
| if (convStatus === 'resolved') resolved++; | |
| // If we already counted it as pending because of NC, we don't double count for comment | |
| } | |
| }); | |
| }); | |
| // Overwrite total to be the number of comments for the sidebar counter consistency | |
| const dossierCommentCount = Object.keys(conversations).filter(k => k.startsWith('dossier-') && conversations[k].thread?.length > 0).length; | |
| total = dossierCommentCount; | |
| } | |
| console.log(`--- Finished refreshing for ${tabId}. Total: ${total} ---`); | |
| this.updateElementText(`${tabId}PendingCount`, pending); | |
| this.updateElementText(`${tabId}ResolvedCount`, resolved); | |
| this.updateElementText(`${tabId}TotalCount`, total); | |
| this.updateElementText(`${tabId}CommentCounter`, total > 0 ? total : ''); | |
| } | |
| updateProgressStats() { | |
| const totalComments = this.getTotalCommentsCount(); | |
| this.updateElementText('commentsCount', totalComments); | |
| const progressPercentage = this.calculateProgressPercentage(); | |
| this.updateElementText('progressPercentage', progressPercentage + '%'); | |
| } | |
| updateNonConformitiesStats() { | |
| const checklistData = this.state.get().checklistData; | |
| const conversations = this.state.get().conversations; | |
| if (!checklistData) return; | |
| const ncNaItems = checklistData.filter(i => ['B', 'C', 'D', 'NA'].includes(i.score)); | |
| this.updateElementText('totalNC', ncNaItems.length); | |
| this.updateElementText('scoreB', checklistData.filter(i => i.score === 'B').length); | |
| this.updateElementText('scoreC', checklistData.filter(i => i.score === 'C').length); | |
| this.updateElementText('scoreD', checklistData.filter(i => i.score === 'D').length); | |
| this.updateElementText('naCount', checklistData.filter(i => i.score === 'NA').length); | |
| const ncComments = ncNaItems.filter(item => { | |
| const fieldId = `nc-${item.uuid}`; | |
| return conversations[fieldId]?.thread?.length > 0; | |
| }).length; | |
| this.updateElementText('commentsNCCount', ncComments); | |
| } | |
| getTotalCommentsCount() { | |
| const conversations = this.state.get().conversations; | |
| return Object.keys(conversations).filter(key => | |
| conversations[key]?.thread?.length > 0 | |
| ).length; | |
| } | |
| calculateProgressPercentage() { | |
| const companyProfileData = this.state.get().companyProfileData; | |
| const checklistData = this.state.get().checklistData; | |
| const totalFields = Object.keys(companyProfileData).length + checklistData.length; | |
| const fieldsWithComments = this.getTotalCommentsCount(); | |
| return totalFields > 0 ? Math.round((fieldsWithComments / totalFields) * 100) : 0; | |
| } | |
| setupTableFilters() { | |
| const createFilterHandler = (tab, filterName) => { | |
| return (e) => { | |
| const value = e.target.value; | |
| const activeFilters = { | |
| ...this.state.get().activeFilters | |
| }; | |
| activeFilters[tab][filterName] = value; | |
| this.state.setState({ | |
| activeFilters | |
| }); | |
| }; | |
| }; | |
| const setupListener = (elementId, eventType, tab, filterName) => { | |
| const element = document.getElementById(elementId); | |
| if (element) { | |
| // To prevent multiple listeners, we can store the handler on the element | |
| const handler = debounce(createFilterHandler(tab, filterName), 300); | |
| if (element._filterHandler) { | |
| element.removeEventListener(eventType, element._filterHandler); | |
| } | |
| element.addEventListener(eventType, handler); | |
| element._filterHandler = handler; | |
| } | |
| }; | |
| // Profile Filters | |
| setupListener('profileCommentStatusFilter', 'change', 'profil', 'status'); | |
| setupListener('profileSearchInput', 'keyup', 'profil', 'search'); | |
| // Checklist Filters | |
| setupListener('chapterFilter', 'change', 'checklist', 'chapter'); | |
| setupListener('scoreFilter', 'change', 'checklist', 'score'); | |
| setupListener('commentStatusFilter', 'change', 'checklist', 'status'); | |
| setupListener('searchInput', 'keyup', 'checklist', 'search'); | |
| // Non-Conformities Filters | |
| setupListener('ncTypeFilter', 'change', 'nonconformites', 'type'); | |
| setupListener('ncChapterFilter', 'change', 'nonconformites', 'chapter'); | |
| setupListener('correctionFilter', 'change', 'nonconformites', 'correction'); | |
| setupListener('ncSearchInput', 'keyup', 'nonconformites', 'search'); | |
| // These are now handled by state changes, but we keep the window binding for the UI buttons | |
| window.showAll = () => this.showAll(); | |
| window.showOnlyWithComments = () => this.showOnlyWithComments(); | |
| // QUICK FILTER BUTTONS (Added) | |
| const setupButtonFilter = (btnId, actions) => { | |
| const btn = document.getElementById(btnId); | |
| if (btn) { | |
| // Remove potential existing listeners if needed, or just add one. | |
| // Since this might be called multiple times, we should probably handle it, but simple add is fine for now if not re-run often. | |
| // Ideally use the same _handler pattern if we want to be safe. | |
| const handler = (e) => { | |
| e.preventDefault(); | |
| const activeFilters = { ...this.state.get().activeFilters }; | |
| actions(activeFilters.checklist); | |
| this.state.setState({ activeFilters }); | |
| }; | |
| if (btn._clickHandler) btn.removeEventListener('click', btn._clickHandler); | |
| btn.addEventListener('click', handler); | |
| btn._clickHandler = handler; | |
| } | |
| }; | |
| setupButtonFilter('filter-nc', (filters) => { | |
| filters.score = 'B,C,D'; | |
| filters.chapter = ''; | |
| // We might want to keep search, but let's prioritize showing NCs clearly. | |
| }); | |
| setupButtonFilter('filter-na', (filters) => { | |
| filters.score = 'NA'; | |
| filters.chapter = ''; | |
| }); | |
| setupButtonFilter('filter-comments-only', (filters) => { | |
| filters.status = 'with_comments'; | |
| }); | |
| setupButtonFilter('filter-all', (filters) => { | |
| filters.chapter = ''; | |
| filters.score = ''; | |
| filters.status = ''; | |
| filters.search = ''; | |
| }); | |
| window.setAuditorTaskFilter = (filterType) => { | |
| const activeFilters = { ...this.state.get().activeFilters }; | |
| if (activeFilters.auditorTasks) { | |
| activeFilters.auditorTasks.filter = filterType; | |
| this.state.setState({ activeFilters }); | |
| } | |
| }; | |
| window.updateCertificationDecision = () => this.updateCertificationDecision(); | |
| } | |
| filterChecklist() { | |
| const filters = this.state.get().activeFilters.checklist; | |
| const { | |
| chapter, | |
| score, | |
| status: commentStatus, | |
| search | |
| } = filters; | |
| // Update UI to reflect state | |
| const chapterFilterEl = document.getElementById('chapterFilter'); | |
| if (chapterFilterEl) chapterFilterEl.value = chapter; | |
| const scoreFilterEl = document.getElementById('scoreFilter'); | |
| if (scoreFilterEl) scoreFilterEl.value = score; | |
| const commentStatusFilterEl = document.getElementById('commentStatusFilter'); | |
| if (commentStatusFilterEl) commentStatusFilterEl.value = commentStatus; | |
| const searchInputEl = document.getElementById('searchInput'); | |
| if (searchInputEl) searchInputEl.value = search; | |
| const rows = document.querySelectorAll('#checklistTableBody tr'); | |
| if (!rows || rows.length === 0) return; | |
| rows.forEach(row => { | |
| if (row.cells.length <= 1) return; // Ignore placeholder rows | |
| let show = true; | |
| const rowChapter = row.dataset.chapter; | |
| const rowScore = row.dataset.score; | |
| const rowCommentStatus = row.dataset.commentStatus; | |
| const rowTextContent = row.textContent.toLowerCase(); | |
| if (chapter && rowChapter !== chapter) { | |
| show = false; | |
| } | |
| if (score) { | |
| const scores = score.split(','); | |
| if (!scores.includes(rowScore)) { | |
| show = false; | |
| } | |
| } | |
| if (commentStatus) { | |
| if (commentStatus === 'with_comments') { | |
| if (rowCommentStatus === 'none') show = false; | |
| } else { | |
| if (rowCommentStatus !== commentStatus) show = false; | |
| } | |
| } | |
| if (search && !rowTextContent.includes(search)) { | |
| show = false; | |
| } | |
| row.style.display = show ? '' : 'none'; | |
| }); | |
| } | |
| filterNonConformities() { | |
| const filters = this.state.get().activeFilters.nonconformites; | |
| const { | |
| type, | |
| chapter, | |
| correction, | |
| search | |
| } = filters; | |
| // Update UI to reflect state | |
| const ncTypeFilterEl = document.getElementById('ncTypeFilter'); | |
| if (ncTypeFilterEl) ncTypeFilterEl.value = type; | |
| const ncChapterFilterEl = document.getElementById('ncChapterFilter'); | |
| if (ncChapterFilterEl) ncChapterFilterEl.value = chapter; | |
| const correctionFilterEl = document.getElementById('correctionFilter'); | |
| if (correctionFilterEl) correctionFilterEl.value = correction; | |
| const ncSearchInputEl = document.getElementById('ncSearchInput'); | |
| if (ncSearchInputEl) ncSearchInputEl.value = search; | |
| const rows = document.querySelectorAll('#nonConformitiesTableBody tr'); | |
| if (!rows || rows.length === 0) return; | |
| rows.forEach(row => { | |
| if (row.cells.length <= 1) return; | |
| let show = true; | |
| const rowType = row.dataset.score; // Assuming type filter is based on score | |
| const rowChapter = row.dataset.chapter; | |
| const rowCommentStatus = row.dataset.commentStatus; | |
| const rowTextContent = row.textContent.toLowerCase(); | |
| if (type) { | |
| const scores = type.split(','); | |
| if (!scores.includes(rowType)) { | |
| show = false; | |
| } | |
| } | |
| if (chapter && rowChapter !== chapter) { | |
| show = false; | |
| } | |
| if (correction) { | |
| const hasComments = rowCommentStatus !== 'none'; | |
| if (correction === 'with' && !hasComments) { | |
| show = false; | |
| } | |
| if (correction === 'without' && hasComments) { | |
| show = false; | |
| } | |
| if (correction === 'pending' && rowCommentStatus !== 'pending') { | |
| show = false; | |
| } | |
| } | |
| if (search && !rowTextContent.includes(search)) { | |
| show = false; | |
| } | |
| row.style.display = show ? '' : 'none'; | |
| }); | |
| } | |
| filterProfileTable() { | |
| const filters = this.state.get().activeFilters.profil; | |
| const { | |
| status, | |
| search | |
| } = filters; | |
| // Update UI to reflect state | |
| const statusElement = document.getElementById('profileCommentStatusFilter'); | |
| if (statusElement) statusElement.value = status; | |
| const searchElement = document.getElementById('profileSearchInput'); | |
| if (searchElement) searchElement.value = search; | |
| const categories = document.querySelectorAll('#companyProfileTable .category-header'); | |
| if (!categories || categories.length === 0) return; | |
| categories.forEach(header => { | |
| const tableContainer = header.nextElementSibling; | |
| if (!tableContainer || !tableContainer.classList.contains('table-container')) return; | |
| const rows = tableContainer.querySelectorAll('tbody tr'); | |
| let visibleRowsInCategory = 0; | |
| rows.forEach(row => { | |
| let show = true; | |
| const rowCommentStatus = row.dataset.commentStatus; | |
| const hasComments = rowCommentStatus !== 'none'; | |
| const rowTextContent = row.textContent.toLowerCase(); | |
| if (status) { | |
| if (status === 'with_comments' && !hasComments) { | |
| show = false; | |
| } | |
| if (status === 'none' && hasComments) { | |
| show = false; | |
| } | |
| if (status === 'pending' && rowCommentStatus !== 'pending') { | |
| show = false; | |
| } | |
| if (status === 'resolved' && rowCommentStatus !== 'resolved') { | |
| show = false; | |
| } | |
| } | |
| if (search && !rowTextContent.includes(search)) { | |
| show = false; | |
| } | |
| row.style.display = show ? '' : 'none'; | |
| if (show) { | |
| visibleRowsInCategory++; | |
| } | |
| }); | |
| // Now hide the whole category if no rows are visible | |
| const shouldShowCategory = visibleRowsInCategory > 0; | |
| header.style.display = shouldShowCategory ? '' : 'none'; | |
| tableContainer.style.display = shouldShowCategory ? '' : 'none'; | |
| }); | |
| } | |
| renderDossierTable() { | |
| const container = document.getElementById('dossierTableContainer'); | |
| if (!container) return; | |
| // Ensure UI state | |
| if (!this.dossierUiState) { | |
| this.dossierUiState = { filter: 'all', openCategories: new Set() }; | |
| } | |
| const state = this.state.get(); | |
| const conversations = state.conversations || {}; | |
| const reviewState = state.dossierReviewState || {}; | |
| const currentFilter = this.dossierUiState.filter; | |
| // Statistics | |
| let allItems = []; | |
| Object.values(DataProcessor.REVIEW_CHECKLIST_STRUCTURE).forEach(cat => { | |
| cat.items.forEach(item => allItems.push({ ...item, categoryKey: cat.titre })); | |
| }); | |
| const counts = { | |
| all: allItems.length, | |
| todo: allItems.filter(i => !reviewState[i.id]).length, | |
| problem: allItems.filter(i => reviewState[i.id] === 'nok').length, | |
| comments: allItems.filter(i => conversations[`dossier-${i.id}`]?.thread?.length > 0).length | |
| }; | |
| // Local Window Helpers | |
| window.setDossierFilter = (f) => { this.dossierUiState.filter = f; this.renderDossierTable(); }; | |
| window.toggleDossierCategory = (k) => { | |
| if (this.dossierUiState.openCategories.has(k)) this.dossierUiState.openCategories.delete(k); | |
| else this.dossierUiState.openCategories.add(k); | |
| this.renderDossierTable(); | |
| }; | |
| const themeColor = 'var(--color-theme-600, #3b82f6)'; | |
| const successColor = 'var(--color-success, #10b981)'; | |
| const dangerColor = 'var(--color-danger, #ef4444)'; | |
| const grayColor = 'var(--color-gray-500, #64748b)'; | |
| let html = ` | |
| <style> | |
| .dossier-modern-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border-primary); margin-bottom: 24px; padding-bottom: 2px; } | |
| .dossier-tab-btn { background: none; border: none; padding: 12px 4px; font-size: 14px; font-weight: 600; color: var(--text-tertiary); cursor: pointer; position: relative; transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .dossier-tab-btn.active { color: ${themeColor}; } | |
| .dossier-tab-btn.active::after { content: ''; position: absolute; bottom: -2px; left: 0; width: 100%; height: 3px; background: ${themeColor}; border-radius: 3px; } | |
| .dossier-card { background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: 12px; margin-bottom: 20px; box-shadow: var(--shadow-sm); overflow: hidden; } | |
| .dossier-cat-header { padding: 20px 24px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; transition: background 0.2s; } | |
| .dossier-cat-header:hover { background: var(--color-gray-50); } | |
| .dossier-item-row { display: grid; grid-template-columns: 1fr auto auto; gap: 24px; padding: 16px 24px; align-items: center; border-top: 1px solid var(--border-primary); } | |
| .dossier-validation-group { display: flex; gap: 8px; } | |
| .dossier-v-btn { height: 36px; padding: 0 16px; border-radius: 18px; border: 1.5px solid var(--border-primary); background: transparent; color: var(--text-secondary); font-size: 11px; font-weight: 700; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 6px; } | |
| .dossier-v-btn:hover { background: var(--color-gray-100); border-color: var(--color-gray-400); } | |
| .btn-ok.active { background: ${successColor}; border-color: ${successColor}; color: white; box-shadow: 0 4px 12px -2px rgba(16, 185, 129, 0.4); } | |
| .btn-nok.active { background: ${dangerColor}; border-color: ${dangerColor}; color: white; box-shadow: 0 4px 12px -2px rgba(239, 68, 68, 0.4); } | |
| .btn-na.active { background: ${grayColor}; border-color: ${grayColor}; color: white; } | |
| .comment-btn-modern { width: 40px; height: 40px; border-radius: 10px; background: var(--color-gray-100); border: none; color: var(--color-gray-500); cursor: pointer; display: flex; align-items: center; justify-content: center; position: relative; transition: all 0.2s; } | |
| .comment-btn-modern:hover { background: var(--color-theme-50); color: ${themeColor}; } | |
| .comment-btn-modern.has-comments { background: var(--color-theme-100); color: ${themeColor}; border: 1px solid var(--color-theme-200); } | |
| .v-badge { position: absolute; top: -6px; right: -6px; background: ${themeColor}; color: white; font-size: 9px; font-weight: 800; min-width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } | |
| .status-dot { width: 8px; height: 8px; border-radius: 4px; background: var(--border-primary); } | |
| .status-dot.complete { background: ${successColor}; } | |
| </style> | |
| <div class="dossier-modern-tabs"> | |
| <button onclick="window.setDossierFilter('all')" class="dossier-tab-btn ${currentFilter === 'all' ? 'active' : ''}">Tout (${counts.all})</button> | |
| <button onclick="window.setDossierFilter('todo')" class="dossier-tab-btn ${currentFilter === 'todo' ? 'active' : ''}">À traiter (${counts.todo})</button> | |
| <button onclick="window.setDossierFilter('problem')" class="dossier-tab-btn ${currentFilter === 'problem' ? 'active' : ''}">Points NOK (${counts.problem})</button> | |
| <button onclick="window.setDossierFilter('comments')" class="dossier-tab-btn ${currentFilter === 'comments' ? 'active' : ''}">Commentaires (${counts.comments})</button> | |
| </div> | |
| <div class="dossier-content-area"> | |
| `; | |
| for (const [key, category] of Object.entries(DataProcessor.REVIEW_CHECKLIST_STRUCTURE)) { | |
| const items = category.items; | |
| const completed = items.filter(i => reviewState[i.id]).length; | |
| const isAllDone = completed === items.length; | |
| const visibleItems = items.filter(item => { | |
| const s = reviewState[item.id]; | |
| const hasC = conversations[`dossier-${item.id}`]?.thread?.length > 0; | |
| if (currentFilter === 'todo') return !s; | |
| if (currentFilter === 'problem') return s === 'nok'; | |
| if (currentFilter === 'comments') return hasC; | |
| return true; | |
| }); | |
| if (visibleItems.length === 0 && currentFilter !== 'all') continue; | |
| const isOpen = this.dossierUiState.openCategories.has(key) || currentFilter !== 'all'; | |
| html += ` | |
| <div class="dossier-card" style="border-left: 5px solid ${isAllDone ? successColor : 'var(--border-primary)'}"> | |
| <div class="dossier-cat-header" onclick="window.toggleDossierCategory('${key}')"> | |
| <div style="display: flex; align-items: center; gap: 16px;"> | |
| <div class="status-dot ${isAllDone ? 'complete' : ''}"></div> | |
| <div> | |
| <h3 style="margin: 0; font-size: 16px; font-weight: 700; color: var(--text-primary);">${category.titre}</h3> | |
| <p style="margin: 4px 0 0 0; font-size: 12px; color: var(--text-tertiary);">${category.sous_titres || ''}</p> | |
| </div> | |
| </div> | |
| <div style="display: flex; align-items: center; gap: 15px;"> | |
| <span style="font-size: 10px; font-weight: 800; color: var(--color-theme-600); cursor: pointer;" onclick="event.stopPropagation(); window.markCategoryNA('${key}')">TOUT N/A</span> | |
| <span style="font-size: 10px; font-weight: 800; color: ${successColor}; cursor: pointer;" onclick="event.stopPropagation(); window.markCategoryValid('${key}')">TOUT VALIDE</span> | |
| <span style="font-size: 13px; font-weight: 700; color: ${isAllDone ? successColor : 'var(--text-tertiary)'}">${completed} / ${items.length}</span> | |
| <i class="fas fa-chevron-down" style="color: var(--color-gray-400); transition: transform 0.3s; transform: rotate(${isOpen ? '180deg' : '0deg'})"></i> | |
| </div> | |
| </div> | |
| <div class="dossier-cat-content" style="display: ${isOpen ? 'block' : 'none'}"> | |
| `; | |
| visibleItems.forEach(item => { | |
| const fieldId = `dossier-${item.id}`; | |
| const conv = conversations[fieldId]; | |
| const threadLen = conv?.thread?.length || 0; | |
| const status = reviewState[item.id]; | |
| html += ` | |
| <div class="dossier-item-row"> | |
| <div class="item-info"> | |
| <div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">${item.nom}</div> | |
| <div style="font-size: 11px; color: var(--text-tertiary); line-height: 1.4;">${item.description || ''}</div> | |
| </div> | |
| <div class="dossier-validation-group"> | |
| <button onclick="window.toggleDossierStatus('${item.id}', 'ok')" class="dossier-v-btn btn-ok ${status === 'ok' ? 'active' : ''}"> | |
| <i class="fas fa-check-circle"></i> VALIDE | |
| </button> | |
| <button onclick="window.toggleDossierStatus('${item.id}', 'nok')" class="dossier-v-btn btn-nok ${status === 'nok' ? 'active' : ''}"> | |
| <i class="fas fa-exclamation-circle"></i> ÉCART | |
| </button> | |
| <button onclick="window.toggleDossierStatus('${item.id}', 'na')" class="dossier-v-btn btn-na ${status === 'na' ? 'active' : ''}"> | |
| N/A | |
| </button> | |
| </div> | |
| <div class="item-actions"> | |
| <button class="comment-btn-modern ${threadLen > 0 ? 'has-comments' : ''}" onclick="const r=this.closest('.dossier-item-row'); r.setAttribute('data-field-id', '${fieldId}'); openCommentModal(r)"> | |
| <i class="fas fa-comment-dots" style="font-size: 18px;"></i> | |
| ${threadLen > 0 ? `<div class="v-badge">${threadLen}</div>` : ''} | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| if (visibleItems.length === 0) { | |
| html += `<div style="padding: 30px; text-align: center; color: var(--text-tertiary); font-style: italic; font-size: 13px;">Aucun point dans cette sélection.</div>`; | |
| } | |
| html += `</div></div>`; | |
| } | |
| html += `</div>`; | |
| container.innerHTML = html; | |
| // Ensure global functions | |
| if (!window.markCategoryNA) { | |
| window.markCategoryNA = (k) => { | |
| const cat = DataProcessor.REVIEW_CHECKLIST_STRUCTURE[k]; | |
| if (!cat) return; | |
| const cur = this.state.get().dossierReviewState || {}; | |
| const upd = {}; | |
| cat.items.forEach(i => { if (!cur[i.id]) upd[i.id] = 'na'; }); | |
| this.state.setState({ dossierReviewState: { ...cur, ...upd } }); | |
| this.renderDossierTable(); | |
| }; | |
| } | |
| if (!window.markCategoryValid) { | |
| window.markCategoryValid = (k) => { | |
| const cat = DataProcessor.REVIEW_CHECKLIST_STRUCTURE[k]; | |
| if (!cat) return; | |
| const cur = this.state.get().dossierReviewState || {}; | |
| const upd = {}; | |
| cat.items.forEach(i => { if (!cur[i.id]) upd[i.id] = 'ok'; }); | |
| this.state.setState({ dossierReviewState: { ...cur, ...upd } }); | |
| this.renderDossierTable(); | |
| }; | |
| } | |
| if (!window.toggleDossierStatus) { | |
| window.toggleDossierStatus = (id, s) => { | |
| const cur = this.state.get().dossierReviewState || {}; | |
| const next = cur[id] === s ? null : s; | |
| this.state.setState({ dossierReviewState: { ...cur, [id]: next } }); | |
| this.renderDossierTable(); | |
| }; | |
| } | |
| } | |
| updateCertificationDecision() { | |
| // Collect data from the form | |
| const date = document.getElementById('decisionDate')?.value; | |
| const maker = document.getElementById('decisionMaker')?.value; | |
| const result = document.getElementById('certificationResult')?.value; | |
| const comments = document.getElementById('decisionComment')?.value; | |
| // Update state | |
| const certificationDecisionData = { | |
| date, | |
| maker, | |
| result, | |
| comments, | |
| lastUpdated: new Date().toISOString() | |
| }; | |
| this.state.setState({ | |
| certificationDecisionData, | |
| hasUnsavedChanges: true | |
| }); | |
| console.log('💾 Certification decision saved to state:', certificationDecisionData); | |
| } | |
| renderCertificationDecision() { | |
| const data = this.state.get().certificationDecisionData || {}; | |
| const dateEl = document.getElementById('decisionDate'); | |
| const makerEl = document.getElementById('decisionMaker'); | |
| const resultEl = document.getElementById('certificationResult'); | |
| const commentEl = document.getElementById('decisionComment'); | |
| if (dateEl) dateEl.value = data.date || ''; | |
| if (makerEl) makerEl.value = data.maker || ''; | |
| if (resultEl) resultEl.value = data.result || ''; | |
| if (commentEl) commentEl.value = data.comments || ''; | |
| } | |
| sanitizeFieldId(str) { | |
| if (!str) return ''; | |
| // Convert to string, lowercase, and replace spaces with hyphens | |
| let sanitized = str.toString().toLowerCase().replace(/\s+/g, '-'); | |
| // Remove all characters that are not alphanumeric, a hyphen, or an underscore | |
| sanitized = sanitized.replace(/[^\w\-]/g, ''); | |
| // Replace multiple hyphens with a single one | |
| sanitized = sanitized.replace(/\-\-+/g, '-'); | |
| // Trim hyphens from the start and end | |
| sanitized = sanitized.replace(/^-+/, '').replace(/-+$/, ''); | |
| return sanitized; | |
| } | |
| } |