class FileHandler { constructor(state, uiManager, dataProcessor) { this.state = state; this.uiManager = uiManager; // Inject UIManager this.dataProcessor = dataProcessor; // Inject DataProcessor this.state.subscribe(this.onStateChange.bind(this)); } setUIManager(uiManager) { this.uiManager = uiManager; } setDataProcessor(dataProcessor) { this.dataProcessor = dataProcessor; } onStateChange(newState) { // Handle state changes here console.log('FileHandler State changed:', newState); } loadFile() { const fileInput = document.getElementById('fileInputInternal'); if (fileInput) fileInput.click(); } handleFileUpload(event) { const file = event.target.files[0]; if (!file) return; const fileName = file.name.toLowerCase(); // Detection Logic const isIFSFile = fileName.endsWith('.ifs'); const isIFSRFile = fileName.endsWith('.ifsr'); const isIFSPFile = fileName.endsWith('.ifsp'); const isExcelFile = fileName.endsWith('.xlsx') || fileName.endsWith('.xls'); if (!isIFSFile && !isIFSRFile && !isIFSPFile && !isExcelFile) { this.uiManager.showError('❌ Type de fichier non supporté !\n\n✅ Formats acceptés :\n• .ifs (nouveau dossier depuis NEO)\n• .ifsr (travail en cours)\n• .ifsp (package)\n• .xlsx (Plan d\'Actions)'); return; } // Handle Excel Import specifically if (isExcelFile) { if (!this.state.get().auditData) { this.uiManager.showError("⚠️ Veuillez d'abord charger un dossier d'audit (.ifs) avant d'importer le plan d'actions."); event.target.value = null; return; } // Delegate to importActionPlanExcel BUT importActionPlanExcel expects an event with target.files // We can reuse the event, or call processExcel directly if we read it here. // Better to call importActionPlanExcel directly passing the event this.importActionPlanExcel(event); return; } this.uiManager.showLoading(true); this.uiManager.simulateProgress(); const reader = new FileReader(); reader.onload = (e) => { try { const content = e.target.result; const data = JSON.parse(content); if (isIFSFile) { this.processNewIFSFile(data); } else if (isIFSRFile) { this.loadWorkInProgress(data); } else if (isIFSPFile) { this.loadCollaborativePackage(data); } } catch (error) { console.error('Error during file processing:', error); this.uiManager.showError(`Erreur lors du traitement du fichier : ${error.message}`); this.uiManager.showLoading(false); this.uiManager.resetToUploadState(); } finally { event.target.value = null; } }; reader.onerror = (error) => { console.error('File reading error:', error); this.uiManager.showError('Erreur lors de la lecture du fichier'); this.uiManager.showLoading(false); this.uiManager.resetToUploadState(); event.target.value = null; }; reader.readAsText(file); } processNewIFSFile(data) { console.log('🎯 PROCESSING NEW IFS FILE'); this.uiManager.setPartialView(false); // Ensure full view this.state.setState({ auditData: data, checklistData: [], companyProfileData: {}, conversations: {}, requirementNumberMapping: {}, packageVersion: 1, certificationDecisionData: {}, dossierReviewState: {}, currentSession: { id: `IFS-${Date.now()}`, name: 'Nouvel Audit', created: new Date(), lastModified: new Date(), data: data } }); if (!data?.data?.modules?.food_8) { this.uiManager.showError('Format de fichier IFS non valide.'); this.uiManager.resetToUploadState(); return; } const food8 = data.data.modules.food_8; this.dataProcessor.processAuditDataLogic(food8); } loadWorkInProgress(workData) { console.log('📂 LOADING WORK IN PROGRESS'); this.uiManager.setPartialView(false); // Ensure full view try { if (!workData.auditData || !workData.version) { throw new Error('Format de fichier IFSR invalide'); } const conversations = this.migrateConversationKeys(workData.conversations || {}); this.state.setState({ auditData: workData.auditData, checklistData: workData.checklistData || [], companyProfileData: workData.companyProfileData || {}, conversations: conversations, requirementNumberMapping: workData.requirementNumberMapping || {}, packageVersion: workData.packageVersion || 1, certificationDecisionData: workData.certificationDecisionData || {}, dossierReviewState: workData.dossierReviewState || {} }); if (workData.currentMode && workData.currentMode !== this.state.get().currentMode) { this.uiManager.selectMode(workData.currentMode, false); } const companyName = workData.companyName || 'Société inconnue'; const coid = workData.coid || 'COID-XXXX'; this.state.setState({ currentSession: { id: workData.sessionId || `IFS-Loaded-${Date.now()}`, name: `Audit ${companyName} (rechargé)`, created: workData.savedDate ? new Date(workData.savedDate) : new Date(), lastModified: new Date(), data: workData.auditData } }); this.uiManager.updateCurrentAuditName(`IFS Reviewer - ${companyName}`); this.uiManager.updateSessionInfo(coid); if (this.state.get().auditData?.data?.modules?.food_8?.result?.overall) { const overallResult = this.state.get().auditData.data.modules.food_8.result.overall; this.uiManager.updateElementText('overallScore', overallResult.percent.toFixed(1) + '%'); } this.uiManager.updateElementText('totalRequirements', this.state.get().checklistData.length); this.uiManager.updateElementText('conformCount', this.state.get().checklistData.filter(item => item.score === 'A').length); this.uiManager.updateElementText('nonConformCount', this.state.get().checklistData.filter(item => ['B', 'C', 'D'].includes(item.score)).length); const totalComments = this.dataProcessor.getTotalCommentsCount(); this.uiManager.showSuccess(`📂 Travail rechargé : ${companyName} (${totalComments} commentaires sauvegardés)`); this.finalizeDataLoad(); } catch (error) { console.error('Error loading work in progress:', error); this.uiManager.showError('Erreur lors du chargement : ' + error.message); this.uiManager.resetToUploadState(); } } migrateConversationKeys(conversations) { if (!conversations) return {}; const newConversations = { ...conversations }; let keysChanged = false; for (const key in newConversations) { // Migrer les anciennes clés 'req-' ou 'nc-' vers 'ckl-' (Canal Constat) if (key.startsWith('req-') || key.startsWith('nc-')) { const uuid = key.startsWith('req-') ? key.replace('req-', '') : key.replace('nc-', ''); const newKey = `ckl-${uuid}`; if (!newConversations[newKey]) { newConversations[newKey] = newConversations[key]; delete newConversations[key]; keysChanged = true; } } } if (keysChanged) { console.log('✅ Migrated old conversation keys to the new dual-channel format (ckl-).'); } return newConversations; } loadCollaborativePackage(packageData) { console.log('📦 LOADING COLLABORATIVE PACKAGE'); try { if (!packageData.version || !packageData.packageType) { throw new Error('Format de package IFSP invalide'); } this.uiManager.setPartialView(packageData.isPartial || false); const expectedMode = packageData.packageType === 'REVIEWER_TO_AUDITOR' ? 'auditor' : 'reviewer'; if (this.state.get().currentMode !== expectedMode) { if (confirm(`Ce package est destiné au mode ${expectedMode === 'auditor' ? 'Auditeur' : 'Reviewer'}. Voulez-vous changer de mode ?`)) { this.uiManager.selectMode(expectedMode, false); } } this.state.setState({ auditData: packageData.auditData, checklistData: packageData.checklistData || [], companyProfileData: packageData.companyProfileData || {}, requirementNumberMapping: packageData.requirementNumberMapping || {}, }); if (packageData.conversations) { const migratedConversations = this.migrateConversationKeys(packageData.conversations); this.mergeConversations(migratedConversations); } this.state.setState({ packageVersion: packageData.packageVersion || 1, }); const companyName = packageData.companyName || 'Société inconnue'; const coid = packageData.coid || 'COID-XXXX'; this.state.setState({ currentSession: { id: `IFSP-${coid}-v${this.state.get().packageVersion}`, name: `Package ${companyName} v${this.state.get().packageVersion}`, created: new Date(packageData.createdDate), lastModified: new Date(), data: packageData.auditData } }); this.uiManager.updateCurrentAuditName(`IFS Reviewer - ${companyName} (Package v${this.state.get().packageVersion})`); this.uiManager.updateSessionInfo(coid); this.uiManager.updateElementText('totalRequirements', this.state.get().checklistData.length); this.uiManager.updateElementText('conformCount', this.state.get().checklistData.filter(item => item.score === 'A').length); this.uiManager.updateElementText('nonConformCount', this.state.get().checklistData.filter(item => ['B', 'C', 'D'].includes(item.score)).length); if (this.state.get().auditData?.data?.modules?.food_8?.result?.overall) { const overallResult = this.state.get().auditData.data.modules.food_8.result.overall; this.uiManager.updateElementText('overallScore', overallResult.percent.toFixed(1) + '%'); } const newComments = this.countNewComments(packageData); this.uiManager.showSuccess(`📦 Package collaboratif chargé : ${companyName} v${this.state.get().packageVersion} (${newComments} nouveaux commentaires)`); this.finalizeDataLoad(); } catch (error) { console.error('Error loading collaborative package:', error); this.uiManager.showError('Erreur lors du chargement du package : ' + error.message); this.uiManager.resetToUploadState(); } } mergeConversations(newConversations) { const currentConversations = { ...this.state.get().conversations }; for (const fieldId in newConversations) { if (!currentConversations[fieldId]) { currentConversations[fieldId] = newConversations[fieldId]; } else { const existingIds = new Set(currentConversations[fieldId].thread.map(msg => msg.id)); const newMessages = newConversations[fieldId].thread.filter(msg => !existingIds.has(msg.id)); currentConversations[fieldId].thread = [...currentConversations[fieldId].thread, ...newMessages]; currentConversations[fieldId].thread.sort((a, b) => new Date(a.date) - new Date(b.date)); currentConversations[fieldId].lastActivity = newConversations[fieldId].lastActivity; } } this.state.setState({ conversations: currentConversations }); } countNewComments(packageData) { let newCount = 0; const otherMode = this.state.get().currentMode === 'reviewer' ? 'auditor' : 'reviewer'; for (const fieldId in packageData.conversations) { const conversation = packageData.conversations[fieldId]; conversation.thread.forEach(message => { if (message.author === otherMode && message.status === 'pending') { newCount++; } }); } return newCount; } finalizeDataLoad() { this.uiManager.showLoading(false); this.uiManager.showResults(true); setTimeout(() => { this.dataProcessor.renderCompanyProfile(); this.dataProcessor.renderChecklistTable(); this.dataProcessor.renderNonConformitiesTable(); this.dataProcessor.refreshAllCounters(); }, 100); } createNewSession() { if (this.state.get().auditData && !confirm("⚠️ Créer un nouveau dossier ? Le travail non sauvegardé sera perdu.")) { return; } this.state.setState({ auditData: null, checklistData: [], companyProfileData: {}, conversations: {}, requirementNumberMapping: {}, packageVersion: 1, certificationDecisionData: {}, dossierReviewState: {}, currentSession: { id: null, name: 'Nouveau Dossier', created: new Date(), lastModified: new Date(), data: null } }); this.uiManager.resetToUploadState(); this.uiManager.resetUI(); this.uiManager.switchTab('profil'); this.uiManager.showSuccess('✅ Nouveau dossier prêt. Chargez un fichier .ifs, .ifsr ou .ifsp.'); } saveWorkInProgress() { if (!this.state.get().auditData) { this.uiManager.showError('❌ Aucune donnée à sauvegarder. Chargez un fichier .ifs d\'abord.'); return; } try { const companyName = this.state.get().companyProfileData['Nom du site à auditer'] || 'Société inconnue'; const coid = this.state.get().companyProfileData['N° COID du portail'] || 'COID-XXXX'; const workPackage = { version: '2.0', packageType: 'WORK_IN_PROGRESS', savedDate: new Date().toISOString(), companyName, coid, currentMode: this.state.get().currentMode, auditData: this.state.get().auditData, checklistData: this.state.get().checklistData, companyProfileData: this.state.get().companyProfileData, conversations: this.state.get().conversations, requirementNumberMapping: this.state.get().requirementNumberMapping, packageVersion: this.state.get().packageVersion, certificationDecisionData: this.state.get().certificationDecisionData, dossierReviewState: this.state.get().dossierReviewState, stats: { totalComments: this.dataProcessor.getTotalCommentsCount(), totalRequirements: this.state.get().checklistData.length, progressPercentage: this.dataProcessor.calculateProgressPercentage() } }; this.downloadFile(workPackage, `TRAVAIL_${coid}_${this.sanitizeFileName(companyName)}_${this.getDateStamp()}.ifsr`); this.state.setState({ hasUnsavedChanges: false }); this.uiManager.showSuccess(`✅ Travail sauvegardé`); } catch (error) { console.error('Error saving work:', error); this.uiManager.showError('❌ Erreur sauvegarde : ' + error.message); } } createPackage() { try { const { companyProfileData, conversations, checklistData, auditData, requirementNumberMapping, currentMode } = this.state.get(); const companyName = companyProfileData['Nom du site à auditer'] || 'Société inconnue'; const coid = companyProfileData['N° COID du portail'] || 'COID-XXXX'; const packageType = currentMode === 'reviewer' ? 'REVIEWER_TO_AUDITOR' : 'AUDITOR_TO_REVIEWER'; const newPackageVersion = this.state.get().packageVersion + 1; this.state.setState({ packageVersion: newPackageVersion }); const packageData = { version: '2.1', // Version updated packageType: packageType, packageVersion: newPackageVersion, isPartial: false, createdBy: currentMode, createdDate: new Date().toISOString(), companyName, coid, auditData: auditData, checklistData: checklistData, companyProfileData: companyProfileData, requirementNumberMapping: requirementNumberMapping, conversations: conversations, metadata: { totalFields: Object.keys(conversations).length, totalComments: this.dataProcessor.getTotalCommentsCount(), lastExchangeDate: new Date().toISOString() } }; const filename = `PACKAGE_${packageType}_${coid}_${this.sanitizeFileName(companyName)}_v${newPackageVersion}_${this.getDateStamp()}.ifsp`; this.downloadFile(packageData, filename); this.state.setState({ hasUnsavedChanges: false }); this.uiManager.showSuccess(`📦 Package partiel créé : ${filename}`); this.uiManager.closePackageModal(); } catch (error) { console.error('Error creating package:', error); this.uiManager.showError('❌ Erreur création package : ' + error.message); } } addNeoUpdateToPackage() { const description = document.getElementById('neoUpdateDescription').value.trim(); const fileInput = document.getElementById('newNeoFile'); const file = fileInput?.files?.[0]; if (!description && !file) { this.uiManager.showError('Veuillez décrire les modifications ou joindre un fichier.'); return; } const createComment = (fileData = null) => { const neoUpdateComment = { id: generateUUID(), author: 'auditor', content: `🔄 MISE À JOUR NEO: ${description}`, date: new Date().toISOString(), status: 'pending', isNeoUpdate: true, version: this.state.get().packageVersion, file: fileData // { name: '...', content: '...' } }; const profileFieldId = 'profile-nom-du-site-à-auditer'; this.dataProcessor.addCommentToConversation(profileFieldId, neoUpdateComment); this.uiManager.showSuccess('🔄 Mise à jour NEO ajoutée au package'); this.uiManager.closeNeoUpdateModal(); this.dataProcessor.refreshAllCounters(); }; if (file) { const reader = new FileReader(); reader.onload = (e) => { createComment({ name: file.name, content: e.target.result }); }; reader.readAsText(file); } else { createComment(); } } exportExcel() { if (!this.state.get().auditData) { this.uiManager.showError('Aucune donnée à exporter.'); return; } try { const wb = XLSX.utils.book_new(); this.addSummarySheet(wb); this.addProfileSheet(wb); this.addNCSheet(wb); this.addChecklistSheet(wb); this.addCommentsSheet(wb); const companyName = this.state.get().companyProfileData['Nom du site à auditer'] || 'audit'; const coid = this.state.get().companyProfileData['N° COID du portail'] || 'COID'; const filename = `RAPPORT_IFS_${coid}_${this.sanitizeFileName(companyName)}_${this.getDateStamp()}.xlsx`; XLSX.writeFile(wb, filename); this.state.setState({ hasUnsavedChanges: false }); this.uiManager.showSuccess(`📊 Rapport Excel généré : ${filename}`); } catch (error) { console.error('Error exporting Excel:', error); this.uiManager.showError('❌ Erreur export Excel : ' + error.message); } } addSummarySheet(wb) { const certData = this.state.get().certificationDecisionData || {}; const summary = [ ['Rapport Importé depuis IFS Reviewer', ''], ['', ''], ['DÉCISION DE CERTIFICATION', ''], ['Date de décision', certData.date || 'Non renseignée'], ['Responsable', certData.maker || 'Non renseigné'], ['Résultat', certData.result || 'Non renseigné'], ['Synthèse / Commentaire', certData.comments || 'Aucun'], ['', ''], ['RÉSUMÉ AUDIT', ''], ['Information Clé', 'Valeur'], ['Entreprise', this.state.get().companyProfileData['Nom du site à auditer'] || 'N/A'], ['COID', this.state.get().companyProfileData['N° COID du portail'] || 'N/A'], ['Score global IFS', document.getElementById('overallScore')?.textContent || '0%'], ['Total exigences', this.state.get().checklistData.length], ['Conformités (A)', this.state.get().checklistData.filter(i => i.score === 'A').length], ['Score B', this.state.get().checklistData.filter(i => i.score === 'B').length], ['Score C', this.state.get().checklistData.filter(i => i.score === 'C').length], ['Score D', this.state.get().checklistData.filter(i => i.score === 'D').length], ['Total commentaires', this.dataProcessor.getTotalCommentsCount()], ['Mode de travail', this.state.get().currentMode], ['Version package', this.state.get().packageVersion] ]; const ws = XLSX.utils.aoa_to_sheet(summary); ws['!cols'] = [{ width: 30 }, { width: 30 }]; XLSX.utils.book_append_sheet(wb, ws, "RÉSUMÉ"); } addProfileSheet(wb) { const profileData = [['Champ', 'Valeur', 'Commentaires']]; Object.entries(this.state.get().companyProfileData).forEach(([field, value]) => { const fieldId = `profile-${this.dataProcessor.sanitizeFieldId(field)}`; const comments = this.dataProcessor.getCommentsText(fieldId); profileData.push([field, value || 'N/A', comments]); }); const ws = XLSX.utils.aoa_to_sheet(profileData); ws['!cols'] = [{ width: 40 }, { width: 40 }, { width: 60 }]; XLSX.utils.book_append_sheet(wb, ws, "PROFIL ENTREPRISE"); } addNCSheet(wb) { const ncData = [['N° Exigence', 'Score', 'Explication', 'Détail', 'Commentaires']]; this.state.get().checklistData .filter(item => ['B', 'C', 'D', 'NA'].includes(item.score)) .forEach(item => { const fieldId = `nc-${item.uuid}`; const comments = this.dataProcessor.getCommentsText(fieldId); ncData.push([ item.requirementNumber, item.score, item.explanation || '-', item.detailedExplanation || '-', comments ]); }); const ws = XLSX.utils.aoa_to_sheet(ncData); ws['!cols'] = [{ width: 15 }, { width: 15 }, { width: 45 }, { width: 45 }, { width: 60 }]; XLSX.utils.book_append_sheet(wb, ws, "NON-CONFORMITÉS"); } addChecklistSheet(wb) { const checklistSheetData = [['N° Exigence', 'Chapitre', 'Score', 'Explication', 'Détail', 'Commentaires']]; this.state.get().checklistData.forEach(item => { const fieldId = `req-${item.uuid}`; const comments = this.dataProcessor.getCommentsText(fieldId); checklistSheetData.push([ item.requirementNumber, item.chapter, item.score, item.explanation || '-', item.detailedExplanation || '-', comments ]); }); const ws = XLSX.utils.aoa_to_sheet(checklistSheetData); ws['!cols'] = [{ width: 15 }, { width: 10 }, { width: 15 }, { width: 40 }, { width: 40 }, { width: 60 }]; XLSX.utils.book_append_sheet(wb, ws, "CHECKLIST COMPLÈTE"); } addCommentsSheet(wb) { const commentsData = [['Champ', 'Auteur', 'Date', 'Contenu', 'Statut']]; Object.entries(this.state.get().conversations).forEach(([fieldId, conversation]) => { const fieldName = this.dataProcessor.getFieldInfo(fieldId).name; conversation.thread.forEach(comment => { commentsData.push([ fieldName, comment.author, formatDate(comment.date), comment.content, this.dataProcessor.getStatusLabel(comment.status) ]); }); }); const ws = XLSX.utils.aoa_to_sheet(commentsData); ws['!cols'] = [{ width: 30 }, { width: 15 }, { width: 20 }, { width: 60 }, { width: 15 }]; XLSX.utils.book_append_sheet(wb, ws, "COMMENTAIRES"); } exportActionPlanForSite() { if (!this.state.get().auditData) { this.uiManager.showError('Aucune donnée à exporter.'); return; } try { const wb = XLSX.utils.book_new(); const conversations = this.state.get().conversations; const checklistData = this.state.get().checklistData; const paData = [['N° Exigence', 'Score', 'Constat (Rappel d\'audit)', 'Questions Reviewer / Corrections demandées', 'Statut']]; Object.entries(conversations).forEach(([fieldId, conv]) => { if (fieldId.startsWith('pa-')) { const uuid = fieldId.replace('pa-', ''); const item = checklistData.find(i => i.uuid === uuid); if (!item) return; const commentsText = conv.thread.map(m => `[${m.author === 'reviewer' ? 'REVIEWER' : 'AUDITEUR'}] ${m.content}`).join('\n---\n'); paData.push([ item.requirementNumber, item.score, item.explanation || '-', commentsText, this.dataProcessor.getConversationStatus(conv) === 'resolved' ? 'VALIDÉ' : 'EN ATTENTE' ]); } }); if (paData.length === 1) { this.uiManager.showError("Aucune question sur le plan d'actions (canal spécifique) n'a été identifiée."); return; } const ws = XLSX.utils.aoa_to_sheet(paData); ws['!cols'] = [{ width: 15 }, { width: 10 }, { width: 40 }, { width: 60 }, { width: 15 }]; XLSX.utils.book_append_sheet(wb, ws, "QUESTIONS SITE P.A."); const companyName = this.state.get().companyProfileData['Nom du site à auditer'] || 'audit'; const filename = `QUESTIONS_PA_SITE_${this.sanitizeFileName(companyName)}_${this.getDateStamp()}.xlsx`; XLSX.writeFile(wb, filename); this.uiManager.showSuccess(`📑 Fichier pour le site généré : ${filename}`); } catch (error) { console.error('Error exporting PA for site:', error); this.uiManager.showError("Erreur lors de l'exportation : " + error.message); } } async exportPDF() { if (!this.state.get().auditData) { this.uiManager.showError('Aucune donnée à exporter.'); return; } try { const { jsPDF } = window.jspdf; const doc = new jsPDF(); const pageWidth = doc.internal.pageSize.width; const pageHeight = doc.internal.pageSize.height; const state = this.state.get(); const profile = state.companyProfileData || {}; const decision = state.certificationDecisionData || {}; const dossierState = state.dossierReviewState || {}; const conversations = state.conversations || {}; const checklistStructure = this.dataProcessor.constructor.REVIEW_CHECKLIST_STRUCTURE; const findVal = (keywords) => { const lowerKeywords = keywords.map(k => k.toLowerCase()); for (const [key, val] of Object.entries(profile)) { const lowerKey = key.toLowerCase(); if (lowerKeywords.some(k => lowerKey.includes(k))) return val; } return null; }; // --- STATUS CHECK (DRAFT OR FINAL) --- let unresolvedDossierPixels = 0; Object.values(checklistStructure).forEach(cat => { cat.items.forEach(i => { if (!dossierState[i.id]) unresolvedDossierPixels++; }); }); const isDecisionMade = decision.date && decision.result; const isDraft = !isDecisionMade || unresolvedDossierPixels > 0; const watermarkText = "DOCUMENT PROVISOIRE - NON VALIDÉ"; // --- STYLING CONSTANTS --- const COLOR_PRIMARY = [15, 23, 42]; // Slate 900 const COLOR_ACCENT = [59, 130, 246]; // Blue 500 const COLOR_SUCCESS = [16, 185, 129]; // Emerald 500 const COLOR_DANGER = [239, 68, 68]; // Red 500 const COLOR_GRAY = [148, 163, 184]; // Slate 400 // --- HELPER FUNCTIONS --- const drawHeader = (title) => { doc.setFillColor(...COLOR_PRIMARY); doc.rect(0, 0, pageWidth, 25, 'F'); doc.setTextColor(255, 255, 255); doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text("IFS NEO REVIEWER", 15, 17); doc.setFontSize(10); doc.setFont('helvetica', 'normal'); doc.text(title, pageWidth - 15, 17, { align: 'right' }); if (isDraft) { doc.setTextColor(200, 200, 200); doc.setFontSize(50); doc.text("PROVISOIRE", pageWidth / 2, pageHeight / 2, { align: 'center', angle: 45, renderingMode: 'fill' }); } }; const drawFooter = (pageNo) => { doc.setTextColor(150); doc.setFontSize(8); const str = `Page ${pageNo}`; doc.text(str, pageWidth - 20, pageHeight - 10, { align: 'right' }); doc.text(`Généré le ${new Date().toLocaleDateString()} - IFS Review Tool`, 20, pageHeight - 10); }; // ================= PAGE 1: COVER & SYNTHESIS ================= drawHeader("SYNTHÈSE DE CERTIFICATION"); // Company Info Box - Structured & Filtered doc.setDrawColor(200); doc.setFillColor(255, 255, 255); // White background // 1. Extract specific fields using smart search const val = (keys) => findVal(keys) || '-'; const siteName = val(['nom du site', 'site name', 'société', 'company']); const coidCode = val(['coid']); const auditDateVal = val(['date audit', 'dates audit', 'période']); const reviewerName = val(['reviewer', 'review par', 'nom du reviewer']) || profile['Reviewer'] || "Non spécifié"; const auditorName = val(['auditeur', 'auditor', 'nom de l\'auditeur']) || profile['Auditeur'] || "Non spécifié"; const reviewDate = new Date().toLocaleDateString(); const scopeEn = val(['scope en', 'audit scope', 'scope english']) || "N/A"; const scopeFr = val(['périmètre', 'scope fr', 'libellé fr']) || "N/A"; // 2. Content Layoutextract const startY = 40; let currentY = startY + 15; const leftCol = 20; const rightCol = 110; doc.setFontSize(14); doc.setTextColor(...COLOR_PRIMARY); doc.setFont('helvetica', 'bold'); doc.text("INFORMATIONS CLÉS", 20, 50); doc.setFontSize(10); doc.setTextColor(50); // Row 1: Site & COID doc.setFont('helvetica', 'bold'); doc.text("Site / Société:", leftCol, currentY); doc.setFont('helvetica', 'normal'); doc.text(String(siteName), leftCol, currentY + 5); doc.setFont('helvetica', 'bold'); doc.text("COID:", rightCol, currentY); doc.setFont('helvetica', 'normal'); doc.text(String(coidCode), rightCol, currentY + 5); currentY += 15; // Row 2: Dates doc.setFont('helvetica', 'bold'); doc.text("Date de l'audit:", leftCol, currentY); doc.setFont('helvetica', 'normal'); doc.text(String(auditDateVal), leftCol, currentY + 5); doc.setFont('helvetica', 'bold'); doc.text("Date de la revue:", rightCol, currentY); doc.setFont('helvetica', 'normal'); doc.text(reviewDate, rightCol, currentY + 5); currentY += 15; // Row 3: People doc.setFont('helvetica', 'bold'); doc.text("Auditeur:", leftCol, currentY); doc.setFont('helvetica', 'normal'); doc.text(String(auditorName), leftCol, currentY + 5); doc.setFont('helvetica', 'bold'); doc.text("Reviewer:", rightCol, currentY); doc.setFont('helvetica', 'normal'); doc.text(String(reviewerName), rightCol, currentY + 5); currentY += 20; // Row 4: Scopes (Full Width) doc.setFont('helvetica', 'bold'); doc.text("Audit Scope (EN):", leftCol, currentY); currentY += 5; doc.setFont('helvetica', 'normal'); const splitScopeEn = doc.splitTextToSize(String(scopeEn), pageWidth - 40); doc.text(splitScopeEn, leftCol, currentY); currentY += (splitScopeEn.length * 5) + 8; doc.setFont('helvetica', 'bold'); doc.text("Périmètre (FR):", leftCol, currentY); currentY += 5; doc.setFont('helvetica', 'normal'); const splitScopeFr = doc.splitTextToSize(String(scopeFr), pageWidth - 40); doc.text(splitScopeFr, leftCol, currentY); currentY += (splitScopeFr.length * 5) + 15; // Draw Border around the dynamic section const boxHeight = currentY - startY; doc.setDrawColor(200); doc.roundedRect(14, startY, pageWidth - 28, boxHeight, 2, 2, 'S'); // S for Stroke only // Adjust Y for next section (Decision) // Adjust Y for next section const nextSectionY = currentY + 15; // Decision Block const decisionY = nextSectionY; doc.setFontSize(14); doc.setTextColor(...COLOR_PRIMARY); doc.text("DÉCISION DE CERTIFICATION", 14, decisionY - 5); if (isDecisionMade) { const isSuccess = ['foundation', 'higher'].includes(decision.result); const boxColor = isSuccess ? [240, 253, 244] : [254, 242, 242]; // Light green or light red const borderColor = isSuccess ? COLOR_SUCCESS : COLOR_DANGER; const textColor = isSuccess ? [21, 128, 61] : [185, 28, 28]; const resultText = decision.result === 'higher' ? "NIVEAU SUPÉRIEUR" : (decision.result === 'foundation' ? "NIVEAU DE BASE" : "NON CERTIFIÉ"); doc.setDrawColor(...borderColor); doc.setFillColor(...boxColor); doc.rect(14, decisionY, pageWidth - 28, 40, 'FD'); doc.setFontSize(16); doc.setTextColor(...textColor); doc.setFont('helvetica', 'bold'); doc.text(resultText, pageWidth / 2, decisionY + 15, { align: 'center' }); doc.setFontSize(10); doc.setTextColor(50); doc.setFont('helvetica', 'normal'); doc.text(`Décision prise par: ${decision.maker || 'N/A'}`, pageWidth / 2, decisionY + 25, { align: 'center' }); doc.text(`Date: ${decision.date || 'N/A'}`, pageWidth / 2, decisionY + 32, { align: 'center' }); } else { doc.setDrawColor(200); doc.setFillColor(245, 245, 245); doc.rect(14, decisionY, pageWidth - 28, 30, 'FD'); doc.setFontSize(11); doc.setTextColor(100); doc.text("Aucune décision de certification n'a encore été enregistrée.", pageWidth / 2, decisionY + 18, { align: 'center' }); } // Synthesis Comment if (decision.comments) { const synthesisY = decision.result ? decisionY + 50 : decisionY + 40; doc.setFontSize(12); doc.setTextColor(...COLOR_PRIMARY); doc.text("Synthèse du Reviewer", 14, synthesisY); doc.setFontSize(10); doc.setTextColor(60); const splitText = doc.splitTextToSize(decision.comments, pageWidth - 30); doc.text(splitText, 14, synthesisY + 8); } drawFooter(1); // ================= PAGE 2: DOSSIER REVIEW TABLE ================= doc.addPage(); drawHeader("REVUE DU DOSSIER"); let dossierRows = []; Object.keys(checklistStructure).sort().forEach(key => { const cat = checklistStructure[key]; // Category Header Row - styled differently in autotable dossierRows.push([{ content: cat.titre.toUpperCase(), colSpan: 3, styles: { fillColor: [248, 250, 252], fontStyle: 'bold', textColor: [71, 85, 105] } }]); cat.items.forEach(item => { const status = dossierState[item.id]; let statusLabel = "À TRAITER"; let comments = ""; // Get conversation preview const fieldId = `dossier-${item.id}`; if (conversations[fieldId]?.thread?.length > 0) { comments = `${conversations[fieldId].thread.length} message(s)`; } if (status === 'ok') statusLabel = "CONFORME"; if (status === 'nok') statusLabel = "NON CONFORME"; if (status === 'na') statusLabel = "N/A"; dossierRows.push([ item.nom, statusLabel, comments ]); }); }); doc.autoTable({ startY: 35, head: [['Point de contrôle', 'Statut', 'Observations']], body: dossierRows, theme: 'grid', styles: { fontSize: 9, cellPadding: 3, lineColor: [226, 232, 240] }, headStyles: { fillColor: COLOR_PRIMARY, textColor: 255, fontStyle: 'bold' }, columnStyles: { 0: { cellWidth: 'auto' }, 1: { cellWidth: 40, fontStyle: 'bold', halign: 'center' }, 2: { cellWidth: 40, fontStyle: 'italic' } }, didParseCell: function (data) { if (data.section === 'body' && data.column.index === 1) { const s = data.cell.raw; if (s === 'CONFORME') data.cell.styles.textColor = COLOR_SUCCESS; if (s === 'NON CONFORME') data.cell.styles.textColor = COLOR_DANGER; if (s === 'N/A') data.cell.styles.textColor = COLOR_GRAY; if (s === 'À TRAITER') data.cell.styles.textColor = [234, 88, 12]; // Orange } } }); drawFooter(2); // ================= PAGE 3: CONVERSATION LOGS ================= doc.addPage(); drawHeader("JOURNAL DES ÉCHANGES"); let activeThreads = []; // Collect all relevant conversations Object.keys(conversations).forEach(fieldId => { const thread = conversations[fieldId].thread; if (thread && thread.length > 0) { const info = this.dataProcessor.getFieldInfo(fieldId); activeThreads.push({ name: info.name, thread: thread, type: info.type }); } }); if (activeThreads.length === 0) { doc.setFontSize(10); doc.setTextColor(100); doc.text("Aucun échange commentaire/réponse enregistré.", 14, 40); } else { let yOffset = 40; activeThreads.forEach((item, index) => { // Check page break if (yOffset > pageHeight - 40) { doc.addPage(); drawHeader("JOURNAL DES ÉCHANGES (Suite)"); yOffset = 40; } doc.setFontSize(11); doc.setTextColor(...COLOR_ACCENT); doc.setFont('helvetica', 'bold'); doc.text(`${item.name}`, 14, yOffset); yOffset += 7; const msgRows = item.thread.map(msg => [ `${msg.author === 'reviewer' ? 'Reviewer' : 'Auditeur'} (${formatDate(msg.date)})`, msg.content ]); doc.autoTable({ startY: yOffset, body: msgRows, theme: 'plain', styles: { fontSize: 8, cellPadding: 2 }, columnStyles: { 0: { cellWidth: 40, fontStyle: 'bold', textColor: [100, 100, 100] }, 1: { cellWidth: 'auto' } }, didDrawPage: function (data) { // Don't draw header } }); yOffset = doc.lastAutoTable.finalY + 10; }); } drawFooter(3); // SAVE // Try to find COID for filename safely let safeCoid = "Draft"; const foundCoid = findVal ? findVal(['coid']) : null; if (foundCoid) safeCoid = this.sanitizeFileName(foundCoid); else if (profile['COID']) safeCoid = this.sanitizeFileName(profile['COID']); const filename = `Rapport_Certification_${safeCoid}.pdf`; doc.save(filename); this.uiManager.showError(`✅ Export PDF réussi : ${filename}`, 3000); // Using showError for generic toast if nice toast not avail } catch (error) { console.error('Error exporting PDF:', error); this.uiManager.showError('❌ Erreur export PDF : ' + error.message); } } sanitizeFileName(name) { return name.replace(/[^a-zA-Z0-9]/g, '_'); } getDateStamp() { return new Date().toISOString().split('T')[0].replace(/-/g, ''); } downloadFile(data, filename) { const dataStr = JSON.stringify(data, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); } importActionPlanExcel(event) { const file = event.target.files[0]; if (!file) return; this.uiManager.showLoading(true); const reader = new FileReader(); reader.onload = (e) => { try { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, { type: 'array' }); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); this.processExcelActionPlan(jsonData); } catch (error) { console.error('Error importing Excel:', error); this.uiManager.showError('❌ Erreur importation Excel : ' + error.message); this.uiManager.showLoading(false); } finally { event.target.value = null; // Reset input } }; reader.onerror = (error) => { console.error('File reading error:', error); this.uiManager.showError('Erreur lors de la lecture du fichier'); this.uiManager.showLoading(false); event.target.value = null; }; reader.readAsArrayBuffer(file); } processExcelActionPlan(rows) { if (!rows || rows.length < 15) { // Need at least header row (12) + data rows console.warn("Fichier Excel court, tentative de lecture..."); } console.log("🔍 Analyse du fichier Excel..."); console.log("Nombre de lignes:", rows.length); let dataStartIndex = 13; // User said Row 14 (Index 13) is data. Row 12 (Index 11) is Header. let colMapping = { num: 0, correction: 4, evidence: 8, action: 9 }; // Verification des headers à la ligne 11 (Row 12) const headerRowIndex = 11; if (rows.length > headerRowIndex) { const headerRow = rows[headerRowIndex].map(c => String(c || '').toLowerCase().trim()); console.log("Header candidat (Row 12):", headerRow); if (headerRow.includes('numéro') || headerRow.includes('numero') || headerRow.includes('requirementno')) { console.log("✅ Header confirmé à la ligne 12."); // Mettre à jour le mapping si les colonnes ont bougé headerRow.forEach((cell, idx) => { const val = String(cell).toLowerCase().trim(); if (val.includes('numéro') || val.includes('numero') || val.includes('exigence')) colMapping.num = idx; else if (val.includes('correction') && val.includes('entreprise')) colMapping.correction = idx; else if (val.includes('preuve') || val.includes('evidence')) colMapping.evidence = idx; else if (val.includes('action corrective') && val.includes('entreprise')) colMapping.action = idx; }); } else { console.warn("⚠️ Header non trouvé à la ligne 12, utilisation des indices par défaut et détection auto."); // Fallback auto detection logic for (let i = 0; i < Math.min(rows.length, 25); i++) { const row = rows[i].map(c => String(c || '').toLowerCase().trim()); if (row.includes('numéro') || row.includes('numero') || row.includes('requirementno')) { dataStartIndex = i + 1; // Start right after header // Update mapping row.forEach((cell, idx) => { const val = String(cell).toLowerCase().trim(); if (val.includes('numéro') || val.includes('numero')) colMapping.num = idx; else if (val.includes('correction')) colMapping.correction = idx; else if (val.includes('preuve')) colMapping.evidence = idx; else if (val.includes('action corrective')) colMapping.action = idx; }); break; } } } } console.log("📊 Mapping colonnes utilisé:", colMapping); console.log("🚀 Début lecture données ligne:", dataStartIndex + 1); let updatedCount = 0; const currentChecklist = [...this.state.get().checklistData]; // Shallow copy of array const numToUUID = {}; currentChecklist.forEach(item => { if (item.requirementNumber) { numToUUID[item.requirementNumber] = item.uuid; } }); for (let i = dataStartIndex; i < rows.length; i++) { const row = rows[i]; if (!row || row.length < 2) continue; const numCell = row[colMapping.num]; if (!numCell) continue; const num = String(numCell).replace(/[\r\n\t]/g, '').trim(); const uuid = numToUUID[num]; if (uuid) { let correctionText = row[colMapping.correction]; let evidenceText = row[colMapping.evidence]; let actionText = row[colMapping.action]; correctionText = correctionText ? String(correctionText).trim() : ''; evidenceText = evidenceText ? String(evidenceText).trim() : ''; actionText = actionText ? String(actionText).trim() : ''; const ignoreValues = ['-', '.', '0', 'nan', 'undefined', 'non renseigné']; if (ignoreValues.includes(correctionText.toLowerCase())) correctionText = ''; if (ignoreValues.includes(evidenceText.toLowerCase())) evidenceText = ''; if (ignoreValues.includes(actionText.toLowerCase())) actionText = ''; if (correctionText || actionText || evidenceText) { const itemIndex = currentChecklist.findIndex(item => item.uuid === uuid); if (itemIndex !== -1) { let modified = false; const item = { ...currentChecklist[itemIndex] }; // Copy object to avoid direct mutation if (correctionText.length > 2) { item.correction = correctionText; modified = true; } if (evidenceText.length > 2) { item.evidence = evidenceText; modified = true; } if (actionText.length > 2) { item.correctiveAction = actionText; modified = true; } if (modified) { currentChecklist[itemIndex] = item; updatedCount++; } } } } } if (updatedCount > 0) { this.state.setState({ checklistData: currentChecklist, hasUnsavedChanges: true }); this.uiManager.showSuccess(`✅ Plan d'Actions importé : ${updatedCount} exigences mises à jour.`); this.dataProcessor.renderChecklistTable(); this.dataProcessor.renderNonConformitiesTable(); } else { this.uiManager.showError("Aucune donnée valide n'a été importée. Vérifiez le format du fichier."); } this.uiManager.showLoading(false); } exportActionPlanForSite() { const state = this.state.get(); const conversations = state.conversations || {}; const checklistData = state.checklistData || []; const companyProfileData = state.companyProfileData || {}; // On cherche les conversations qui commencent par 'pa-' (Plan d'Actions) const paEntries = Object.entries(conversations).filter(([key, conv]) => key.startsWith('pa-') && conv.thread && conv.thread.length > 0 ); if (paEntries.length === 0) { this.uiManager.showError("Aucune question sur le Plan d'Actions (canal spécifique) n'a été trouvée."); return; } const header = [ "N° Exigence", "Score", "Constat (Audit)", "Échanges / Questions (Reviewer)", "Réponse Site (Correction)", "Preuves", "Action Corrective", "Statut" ]; const rows = paEntries.map(([fieldId, conv]) => { const uuid = fieldId.replace('pa-', ''); const item = checklistData.find(i => i.uuid === uuid); // On compile le fil de discussion pour l'affichage const discussion = conv.thread.map(m => `[${m.author === 'reviewer' ? 'REVIEWER' : 'AUDITEUR'} ${new Date(m.date).toLocaleDateString()}] : ${m.content}` ).join('\n\n'); return [ item ? item.requirementNumber : '?', item ? item.score : '?', item ? (item.explanation || '-') : '-', discussion, item ? (item.correction || '') : '', item ? (item.evidence || '') : '', item ? (item.correctiveAction || '') : '', conv.status === 'resolved' ? 'VALIDÉ' : 'EN ATTENTE' ]; }); try { const worksheet = XLSX.utils.aoa_to_sheet([header, ...rows]); // Style de base pour les colonnes worksheet['!cols'] = [ { width: 12 }, // N° { width: 8 }, // Score { width: 40 }, // Constat { width: 60 }, // Discussion { width: 30 }, // Correction { width: 30 }, // Preuves { width: 30 }, // AC { width: 15 } // Statut ]; const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "Questions Plan Actions"); // Nom de fichier propre const companyName = companyProfileData['Nom du site à auditer'] || 'Site'; const coid = companyProfileData['N° COID du portail'] || 'COID'; const dateStamp = this.getDateStamp(); const filename = `QUESTIONS_PA_${this.sanitizeFileName(coid)}_${this.sanitizeFileName(companyName)}_${dateStamp}.xlsx`; XLSX.writeFile(workbook, filename); this.uiManager.showSuccess(`✅ Export réussi : ${filename}`); } catch (error) { console.error('Error exporting PA for site:', error); this.uiManager.showError("Erreur lors de l'exportation Excel : " + error.message); } } generateActionPlanPrintView() { const state = this.state.get(); const conversations = state.conversations || {}; const checklistData = state.checklistData || []; const profile = state.companyProfileData || {}; const paEntries = Object.entries(conversations).filter(([key, conv]) => key.startsWith('pa-') && conv.thread && conv.thread.length > 0 ); if (paEntries.length === 0) { this.uiManager.showError("Aucune question sur le Plan d'Actions n'a été trouvée."); return; } const siteName = profile['Nom du site à auditer'] || 'Site'; const coid = profile['N° COID du portail'] || 'N/A'; let html = `
Site : ${siteName} (COID: ${coid})
Date : ${new Date().toLocaleDateString()}
Site : ${siteName}
`; paEntries.forEach(([fieldId, conv]) => { const uuid = fieldId.replace('pa-', ''); const item = checklistData.find(i => i.uuid === uuid); if (!item) return; // Only get the latest reviewer comment for the "ultra simple" view const reviewerComments = conv.thread.filter(m => m.author === 'reviewer'); const lastQuestion = reviewerComments.length > 0 ? reviewerComments[reviewerComments.length - 1].content : "Voir fil de discussion"; html += `