/** * ========================================================================= * PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0 * Interface Client B2B (Tableau de bord, Facturation, Gestion des panneaux) * ========================================================================= */ const { useState, useEffect, useMemo, useRef } = React; const { Home, Building, MessageSquare, FileText, UserCircle, Plus, Search, CheckCircle, AlertTriangle, Shield, Copy, Archive, Trash2, Edit, Eye, CreditCard, Download, Loader, LogOut, ArrowLeft, KeyRound, ShieldAlert, Lock, ShieldCheck, Mail, Users, Bell, Power, Settings, FileCheck, Image, X, Zap, Package, Share2, RefreshCw, MapPin, ExternalLink, ArrowUp } = window; // ========================================================================= // 1. COMPOSANT STRIPE : FORMULAIRE DE PAIEMENT // ========================================================================= const StripePaymentForm = ({ clientSecret, onSuccess, onCancel, amountCents }) => { const stripePromise = useMemo(() => window.Stripe(window.ECO_CONFIG.stripePubKey), []); const options = useMemo(() => ({ clientSecret, appearance: { theme: 'stripe', variables: { colorPrimary: '#10b981', borderRadius: '12px' } } }), [clientSecret]); const CheckoutForm = () => { const stripeInstance = window.ReactStripe.useStripe(); const elements = window.ReactStripe.useElements(); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); const handleSubmit = async (e) => { e.preventDefault(); if (!stripeInstance || !elements) return; setIsProcessing(true); setError(null); const { error: submitError } = await elements.submit(); if (submitError) { setError(submitError.message); setIsProcessing(false); return; } const { error: confirmError, paymentIntent } = await stripeInstance.confirmPayment({ elements, confirmParams: { return_url: window.location.href }, redirect: "if_required" }); if (confirmError) { setError(confirmError.message); setIsProcessing(false); } else if (paymentIntent && paymentIntent.status === "succeeded") { onSuccess(paymentIntent.id); } else { setError("Statut de paiement inattendu."); setIsProcessing(false); } }; return (

Montant à régler : {amountCents ? (amountCents / 100).toFixed(2) : "0.00"} € TTC

{error &&
{error}
}
); }; return ( ); }; // ========================================================================= // 2. VUE PRINCIPALE DU CLIENT B2B // ========================================================================= window.ClientView = ({ data, refreshData, showToast }) => { const [activeTab, setActiveTab] = useState('dashboard'); const [uiMode, setUiMode] = useState('simplifie'); const [pushEnabled, setPushEnabled] = useState(localStorage.getItem('eco_push_enabled') === 'true' && window.Notification?.permission === 'granted'); const [mobileChatView, setMobileChatView] = useState(false); // Modales partagées natives-like const [confirmDialog, setConfirmDialog] = useState(null); const [promptDialog, setPromptDialog] = useState(null); const [legalModal, setLegalModal] = useState(null); // 'cgv' | 'rgpd' const [archiveConfig, setArchiveConfig] = useState(null); const [managingPanneau, setManagingPanneau] = useState(null); const [editingEntity, setEditingEntity] = useState(null); const [draggedItem, setDraggedItem] = useState(null); const [isSaving, setIsSaving] = useState(false); // Renonciation légale au droit de rétractation const [waiverAccepted, setWaiverAccepted] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const [modalStep, setModalStep] = useState('config_full'); const [validationErrors, setValidationErrors] = useState([]); const [paymentData, setPaymentData] = useState(null); const [previewPanneau, setPreviewPanneau] = useState(null); // Coffre-fort légal const [vaultDragging, setVaultDragging] = useState(false); const [viewingDoc, setViewingDoc] = useState(null); // Gestionnaire de liens délégués const [newLink, setNewLink] = useState({ name: '', expDays: 7, lockedFields: [] }); // Modale de confirmation de livraison const [deliveryConfirmModal, setDeliveryConfirmModal] = useState(null); const [profileData, setProfileData] = useState({ type_client: 'entreprise', employees_gt_5: false, name: '', address: '', siret: '', tva: '', monthlyReport: 0, two_factor_method: 'none' }); const [hasTva, setHasTva] = useState(true); const [showTvaWarning, setShowTvaWarning] = useState(false); const [passwordData, setPasswordData] = useState({ oldPassword: '', newPassword: '' }); const [teamEmail, setTeamEmail] = useState(''); const [totpSetupData, setTotpSetupData] = useState(null); const [meRes, setMeRes] = useState(null); useEffect(() => { fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/me').then(r=>r.json()).then(d=>setMeRes(d.data)); }, []); const myClientData = data.clients?.find(c => c.id === meRes?.user_id) || data.clients?.[0] || {}; const isSuspended = myClientData.paymentStatus === 'suspended'; const isCollaborator = !data.team; const isImpersonating = meRes?.is_impersonating; // CORRECTION : Protection de la date avec String() const isSupportLocked = myClientData.admin_access_until && new Date(String(myClientData.admin_access_until || '').replace(' ', 'T')) > new Date(); const isLockedForClient = isSupportLocked && !isImpersonating; const purchasesAllowed = data.settings?.allow_new_purchases !== '0'; const profileLoadedRef = useRef(false); useEffect(() => { if (myClientData.id && !profileLoadedRef.current) { if (myClientData.uiMode) setUiMode(myClientData.uiMode); const isTvaExempt = myClientData.tva === 'NON_ASSUJETTI'; setHasTva(!isTvaExempt); setProfileData({ type_client: myClientData.type_client || 'entreprise', employees_gt_5: myClientData.employees_gt_5 == 1 || myClientData.employees_gt_5 === true, name: myClientData.name || '', address: myClientData.address || '', siret: myClientData.siret || '', tva: isTvaExempt ? '' : (myClientData.tva || ''), monthlyReport: myClientData.monthlyReport || 0, two_factor_method: myClientData.two_factor_method || 'none' }); profileLoadedRef.current = true; } }, [myClientData]); // Écouteur de copier/coller pour le coffre-fort useEffect(() => { if (modalStep !== 'legal_vault' || !isModalOpen) return; const handlePaste = (e) => { if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) { handleVaultUpload(e.clipboardData.files); } }; window.addEventListener('paste', handlePaste); return () => window.removeEventListener('paste', handlePaste); }, [modalStep, isModalOpen, managingPanneau]); useEffect(() => { const url = new URL(window.location); const confirmDelivery = url.searchParams.get('confirm_delivery'); if (confirmDelivery) { setActiveTab('livraisons'); setDeliveryConfirmModal(confirmDelivery); url.searchParams.delete('confirm_delivery'); window.history.replaceState({}, '', url); } }, []); const handleConfirmDelivery = () => { if (!deliveryConfirmModal) return; setIsSaving(true); fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/shipping', { method: 'POST', body: JSON.stringify({ id: deliveryConfirmModal, shipping_status: 'Livré' }) }).then(res => res.json()).then(d => { if (d.status === 'success') { showToast("Réception confirmée, merci !"); refreshData(); setDeliveryConfirmModal(null); } else { showToast(d.message || "Une erreur est survenue", "error"); } }).finally(() => setIsSaving(false)); }; const activePanneaux = data.panneaux?.filter(c => c.status === 'Actif') || []; const draftPanneaux = data.panneaux?.filter(c => c.status === 'Brouillon') || []; const interactions = data.interactions || []; const totalUnread = interactions.filter(m => !m.resolved && m.author !== 'Client').length; const activeDeliveries = data.panneaux?.filter(c => c.physicalPanels > 0 && c.shipping_status !== 'Livré') || []; // --- CALCUL DES TARIFS GLOBAUX UTILES A LA PAGE --- const pRental = data.prices?.rentalMo ?? 180; const pPurchase = data.prices?.purchase ?? 850; const pHosting = data.prices?.hostingYr ?? 1; const pBoardFirst = data.prices?.boardFirst ?? 250; const pBoardAdd = data.prices?.boardAdd ?? 150; const pNoAds = data.prices?.noAds ?? 150; const originalPanneau = data.panneaux?.find(p => p.id === managingPanneau?.id); const isActif = originalPanneau?.status === 'Actif'; const originalOfferType = originalPanneau?.offerType || 'rental'; const originalPanels = originalPanneau?.physicalPanels || 0; const originalHasNoAds = originalPanneau?.hasNoAds || false; const currentOfferType = managingPanneau?.offerType || 'rental'; const currentPanels = managingPanneau?.physicalPanels || 0; const currentHasNoAds = managingPanneau?.hasNoAds || false; let addedPanelsCost = 0; if (currentPanels > originalPanels) { const costNew = currentPanels > 0 ? pBoardFirst + (currentPanels - 1) * pBoardAdd : 0; const costOld = originalPanels > 0 ? pBoardFirst + (originalPanels - 1) * pBoardAdd : 0; addedPanelsCost = Math.max(0, costNew - costOld); } let upfrontHT = 0; if (!isActif) { upfrontHT = (currentPanels > 0 ? pBoardFirst + (currentPanels - 1) * pBoardAdd : 0) + (currentHasNoAds ? pNoAds : 0) + (currentOfferType === 'rental' ? pRental : pPurchase + pHosting); } else { if (originalOfferType === 'rental' && currentOfferType === 'purchase') upfrontHT += pPurchase + pHosting; if (currentHasNoAds && !originalHasNoAds) upfrontHT += pNoAds; upfrontHT += addedPanelsCost; } const discountPct = myClientData.discount || 0; const priceMultiplier = Math.max(0, (100 - discountPct) / 100); const platformHasTva = data.settings?.billing_has_tva !== '0'; const tvaMult = platformHasTva ? 1.20 : 1.00; const finalUpfrontTTC = upfrontHT * priceMultiplier * tvaMult; const finalMonthlyTTC = pRental * priceMultiplier * tvaMult; const handleLogout = async () => { await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/logout'); window.location.href = '?'; }; const handleUnimpersonate = async () => { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/unimpersonate', { method: 'POST' }); const d = await res.json(); if (d.status === 'success') { window.location.href = '?'; } else { showToast(d.message, 'error'); } }; const handleRevokeAccess = async () => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/revoke_access', { method: 'POST' }); if ((await res.json()).status === 'success') { showToast("Accès révoqué avec succès !", "success"); if (isImpersonating) window.location.href = '?'; else { refreshData(); setActiveTab('dashboard'); } } } finally { setIsSaving(false); } }; let finalCustomLogout = null; if (isLockedForClient) { finalCustomLogout = { label: "Stopper l'assistance", icon: , action: handleRevokeAccess, className: "bg-red-600 hover:bg-red-700 text-white" }; } else if (isImpersonating) { finalCustomLogout = { label: "Clôturer l'intervention", icon: , action: handleRevokeAccess, className: "bg-emerald-600 hover:bg-emerald-700 text-white" }; } let finalNavItems = [ { id: 'dashboard', icon: , label: 'Tableau de bord', badge: 0 }, { id: 'panels', icon: , label: 'Mes panneaux', badge: 0 }, { id: 'livraisons', icon: , label: 'Livraisons', badge: activeDeliveries.length }, { id: 'messages', icon: , label: 'Messagerie', badge: totalUnread }, ...(isCollaborator ? [] : [{ id: 'billing', icon: , label: 'Factures', badge: 0 }]), { id: 'account', icon: , label: 'Mon compte', badge: 0 }, ]; if (isLockedForClient) { finalNavItems = [ { id: 'messages', icon: , label: 'Messagerie', badge: totalUnread } ]; if (activeTab !== 'messages') setActiveTab('messages'); } const saveEditedEntity = () => { let newInter = [...(managingPanneau.intervenants || [])]; let newLots = JSON.parse(JSON.stringify(managingPanneau.lots || [])); const loc = editingEntity.location; if (loc.type === 'intervenant') { if (loc.index !== undefined) newInter[loc.index] = editingEntity.data; else newInter.push({...editingEntity.data, id: crypto.randomUUID()}); } else if (loc.type === 'lot') { if (loc.index !== undefined) newLots[loc.index] = {...newLots[loc.index], ...editingEntity.data}; else newLots.push({...editingEntity.data, id: crypto.randomUUID(), entreprises: []}); } else if (loc.type === 'entreprise') { if (loc.index !== undefined) newLots[loc.lotIndex].entreprises[loc.index] = editingEntity.data; else newLots[loc.lotIndex].entreprises.push({...editingEntity.data, id: crypto.randomUUID()}); } setManagingPanneau({ ...managingPanneau, intervenants: newInter, lots: newLots }); setEditingEntity(null); }; const deleteEntity = (loc) => { setConfirmDialog({ title: "Supprimer l'élément", message: "Êtes-vous sûr de vouloir supprimer cet élément ?", confirmText: "Supprimer", isDestructive: true, onConfirm: () => { let newInter = [...(managingPanneau.intervenants || [])]; let newLots = JSON.parse(JSON.stringify(managingPanneau.lots || [])); if (loc.type === 'intervenant') newInter.splice(loc.index, 1); else if (loc.type === 'lot') newLots.splice(loc.index, 1); else newLots[loc.lotIndex].entreprises.splice(loc.index, 1); setManagingPanneau({ ...managingPanneau, intervenants: newInter, lots: newLots }); } }); }; const handleDragStart = (e, location) => { setDraggedItem(location); e.dataTransfer.effectAllowed = 'move'; }; const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleDrop = (e, dropLocation) => { e.preventDefault(); if (!draggedItem || draggedItem.type !== dropLocation.type) return; if (draggedItem.type === 'entreprise' && draggedItem.lotIndex !== dropLocation.lotIndex) return; let newInter = [...(managingPanneau.intervenants || [])]; let newLots = JSON.parse(JSON.stringify(managingPanneau.lots || [])); if (draggedItem.type === 'intervenant') { const item = newInter.splice(draggedItem.index, 1)[0]; newInter.splice(dropLocation.index, 0, item); } else if (draggedItem.type === 'lot') { const item = newLots.splice(draggedItem.index, 1)[0]; newLots.splice(dropLocation.index, 0, item); } else if (draggedItem.type === 'entreprise') { const arr = newLots[dropLocation.lotIndex].entreprises; const item = arr.splice(draggedItem.index, 1)[0]; arr.splice(dropLocation.index, 0, item); } setManagingPanneau({ ...managingPanneau, intervenants: newInter, lots: newLots }); setDraggedItem(null); }; const validatePanel = () => { const errors = []; if (!managingPanneau.name?.trim()) errors.push("Le nom du chantier est manquant."); if (!managingPanneau.location?.trim()) errors.push("L'adresse du panneau est manquante."); if (!managingPanneau.pdfId) errors.push("L'arrêté légal (PDF) n'a pas été uploadé."); if (!isCollaborator) { if (!profileData.name?.trim()) errors.push("Facturation : raison sociale ou nom manquant (onglet Mon compte)."); if (!profileData.address?.trim()) errors.push("Facturation : adresse postale manquante (onglet Mon compte)."); if (profileData.type_client === 'entreprise' && !profileData.siret?.trim()) errors.push("Facturation : SIRET de l'entreprise manquant (onglet Mon compte)."); } return errors; }; const handleSavePanneau = async (forceDraft = false) => { if (!forceDraft && managingPanneau.status === 'Actif') { const errs = validatePanel(); if (errs.length > 0) { setValidationErrors(errs); return; } } setIsSaving(true); try { const payload = { id: managingPanneau.id, status: managingPanneau.status, offerType: managingPanneau.offerType, currentRate: managingPanneau.currentRate, physicalPanels: managingPanneau.physicalPanels, details: { ...managingPanneau, name: managingPanneau.name?.trim() || 'Brouillon sans nom', location: managingPanneau.location?.trim() || '' } }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify(payload) }); const d = await res.json(); if(d.status === 'success') { showToast(forceDraft ? "Brouillon sauvegardé !" : "Panneau mis à jour !"); setIsModalOpen(false); refreshData(); } else showToast(d.message, 'error'); } catch(e) { showToast("Erreur de sauvegarde", "error"); } setIsSaving(false); }; const handleActivationRequest = async () => { if(isSuspended) return showToast("Votre compte est suspendu. Veuillez régulariser vos factures.", "error"); // Si le client n'a pas assez de solde pour payer, la CB est requise const remainingToPay = Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)); if(remainingToPay > 0 && (!waiverAccepted || !purchasesAllowed)) { if (!purchasesAllowed) return showToast("Les nouvelles commandes sont suspendues.", "error"); return showToast("Veuillez accepter les conditions de rétractation.", "error"); } setIsSaving(true); try { const payload = { panneau_id: managingPanneau.id, offer_type: managingPanneau.offerType, physical_panels: managingPanneau.physicalPanels, hasNoAds: managingPanneau.hasNoAds }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'stripe/intent', { method: 'POST', body: JSON.stringify(payload) }); const d = await res.json(); if(d.status === 'success') { if(d.data.free_activation_required) { const freeRes = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/activate_free', { method: 'POST', body: JSON.stringify({ ...payload, id: managingPanneau.id, offerType: managingPanneau.offerType, physicalPanels: managingPanneau.physicalPanels, details: managingPanneau, name: managingPanneau.name }) }); if((await freeRes.json()).status === 'success') { showToast("Mise à jour et activation réussie !"); setIsModalOpen(false); refreshData(); } else showToast("Erreur d'activation", "error"); } else { setPaymentData({ secret: d.data.clientSecret, subId: d.data.subscription_id, amountCents: d.data.amountCents }); setModalStep('payment'); } } else showToast(d.message, 'error'); } catch(e) { showToast("Erreur de communication", "error"); } setIsSaving(false); }; const handlePaymentSuccess = async (pi_id) => { setIsSaving(true); try { const payload = { payment_intent_id: pi_id, subscription_id: paymentData?.subId, id: managingPanneau.id, offerType: managingPanneau.offerType, currentRate: managingPanneau.currentRate, physicalPanels: managingPanneau.physicalPanels, details: managingPanneau, name: managingPanneau.name }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/activate_paid', { method: 'POST', body: JSON.stringify(payload) }); const d = await res.json(); if(d.status === 'success') { showToast("Paiement validé !"); setIsModalOpen(false); refreshData(); } else showToast(d.message, 'error'); } catch(e) { showToast("Erreur", "error"); } setIsSaving(false); }; const handleCreateDelegateLink = async () => { if (!newLink.name.trim()) return showToast("Veuillez donner un nom au lien.", "error"); setIsSaving(true); try { const linkId = crypto.randomUUID(); const expTime = Math.floor(Date.now() / 1000) + (newLink.expDays * 86400); const linkObj = { id: linkId, name: newLink.name.trim(), exp: expTime, active: true, lockedFields: newLink.lockedFields }; const updatedPanneau = { ...managingPanneau, delegate_links: [...(managingPanneau.delegate_links || []), linkObj] }; await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) }); setManagingPanneau(updatedPanneau); setNewLink({ name: '', expDays: 7, lockedFields: [] }); refreshData(); showToast("Nouveau lien de saisie créé.", "success"); } catch(e) { showToast("Erreur lors de la création", "error"); } setIsSaving(false); }; const handleDeleteTeamMember = (uid) => { setConfirmDialog({ title: "Supprimer le collaborateur", message: "Voulez-vous vraiment supprimer ce collaborateur ?", isDestructive: true, confirmText: "Supprimer", onConfirm: async () => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/team/delete', { method: 'POST', body: JSON.stringify({ uid }) }); if ((await res.json()).status === 'success') { showToast("Collaborateur supprimé."); refreshData(); } } finally { setIsSaving(false); } } }); }; const handleAssignPanel = async (panneauId, assignedUid) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/assign', { method: 'POST', body: JSON.stringify({ panneau_id: panneauId, assigned_uid: assignedUid }) }); if ((await res.json()).status === 'success') { showToast("Assignation mise à jour."); refreshData(); } } finally { setIsSaving(false); } }; const handleAutoFill = () => { setPromptDialog({ title: "Saisie automatique", message: "Saisissez votre numéro SIRET (14 chiffres) ou SIREN (9 chiffres) :", placeholder: "N° SIREN ou SIRET", confirmText: "Rechercher", onConfirm: async (num) => { const cleanNum = num.replace(/\D/g, ''); if (cleanNum.length !== 9 && cleanNum.length !== 14) return showToast("Le numéro doit contenir exactement 9 ou 14 chiffres.", "error"); setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/sirene', { method: 'POST', body: JSON.stringify({ q: cleanNum }) }); const responseData = await res.json(); if (responseData.status !== 'success') throw new Error(responseData.message); const apiData = responseData.data; if (apiData.results && apiData.results.length > 0) { const company = apiData.results[0]; const expectedSiren = cleanNum.substring(0, 9); if (company.siren !== expectedSiren) { setIsSaving(false); return showToast("Numéro invalide.", "error"); } const siege = company.siege || {}; let foundSiret = siege.siret || company.siren + "00010"; if (cleanNum.length === 14) { if (siege.siret === cleanNum) foundSiret = cleanNum; else if (company.matching_etablissements) { const exactEtab = company.matching_etablissements.find(e => e.siret === cleanNum); if (exactEtab) foundSiret = cleanNum; } } const addressParts = [siege.numero_voie, siege.indice_repetition, siege.type_voie, siege.libelle_voie, siege.code_postal, siege.libelle_commune]; const address = addressParts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() || "Adresse non renseignée"; const tvaKey = (12 + 3 * (parseInt(company.siren, 10) % 97)) % 97; const calculatedTva = `FR${tvaKey.toString().padStart(2, '0')}${company.siren}`; setProfileData({ ...profileData, type_client: 'entreprise', name: company.nom_complet, address: address, siret: foundSiret, tva: calculatedTva }); setHasTva(true); showToast("Informations récupérées !", "success"); } else showToast("Aucune entreprise trouvée.", "error"); } catch (err) { showToast("Le service est indisponible.", "error"); } setIsSaving(false); } }); }; const handleToggleTva = () => { if (isCollaborator) return; if (hasTva) setShowTvaWarning(true); else setHasTva(true); }; const confirmTvaExemption = () => { setHasTva(false); setProfileData({ ...profileData, tva: '' }); setShowTvaWarning(false); }; const handleSaveProfile = async () => { if(!profileData.name.trim() || !profileData.address.trim()) { return showToast("Veuillez remplir le nom et l'adresse.", "error"); } if (profileData.type_client === 'entreprise' && !profileData.siret.trim()) { return showToast("Veuillez remplir le SIRET.", "error"); } let cleanTva = 'NON_ASSUJETTI'; if (profileData.type_client === 'entreprise' && hasTva) { if (!profileData.tva || profileData.tva.trim() === '') return showToast("Veuillez renseigner votre numéro de TVA.", "error"); cleanTva = profileData.tva.replace(/\s+/g, '').toUpperCase(); if (!/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(cleanTva)) return showToast("Format de TVA invalide.", "error"); } setIsSaving(true); try { const payload = { ...profileData, tva: cleanTva, uiMode: uiMode }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update', { method: 'POST', body: JSON.stringify(payload) }); if((await res.json()).status === 'success') { setProfileData({ ...profileData, tva: profileData.type_client === 'entreprise' && hasTva ? cleanTva : '' }); showToast("Profil mis à jour !"); refreshData(); } } finally { setIsSaving(false); } }; const handleUiModeChange = async (isAdvanced) => { const newMode = isAdvanced ? 'professionnel' : 'simplifie'; setUiMode(newMode); setIsSaving(true); try { let cleanTva = 'NON_ASSUJETTI'; if (profileData.type_client === 'entreprise' && hasTva && profileData.tva) cleanTva = profileData.tva.replace(/\s+/g, '').toUpperCase(); const payload = { ...profileData, tva: cleanTva, uiMode: newMode }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update', { method: 'POST', body: JSON.stringify(payload) }); if((await res.json()).status === 'success') { showToast(isAdvanced ? "Interface avancée activée." : "Interface simplifiée activée."); refreshData(); } } catch(e) { showToast("Erreur", "error"); } finally { setIsSaving(false); } }; const handleSavePassword = async () => { if(!passwordData.oldPassword || !passwordData.newPassword) return showToast("Champs manquants", "error"); setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/password/update', { method: 'POST', body: JSON.stringify({ oldPassword: passwordData.oldPassword, password: passwordData.newPassword }) }); const d = await res.json(); if(d.status === 'success') { showToast("Mot de passe mis à jour"); setPasswordData({oldPassword:'', newPassword:''}); } else showToast(d.message, 'error'); } finally { setIsSaving(false); } }; const handleInviteTeam = async () => { if(!teamEmail) return; setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/invite_team', { method: 'POST', body: JSON.stringify({ email: teamEmail }) }); const d = await res.json(); if(d.status === 'success') { showToast("Invitation envoyée !"); setTeamEmail(''); refreshData(); } else showToast(d.message, 'error'); } finally { setIsSaving(false); } }; const requestAccountDeletion = () => { setPromptDialog({ title: "Suppression du compte", message: "Pour confirmer la suppression de votre compte, veuillez saisir votre mot de passe :", inputType: "password", placeholder: "Mot de passe", confirmText: "Confirmer la suppression", onConfirm: async (pwd) => { try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/request_delete', { method: 'POST', body: JSON.stringify({ password: pwd }) }); const d = await res.json(); if (d.status === 'success') showToast("E-mail de confirmation envoyé.", "info"); else showToast(d.message, 'error'); } catch(e) { showToast("Erreur", "error"); } } }); }; const togglePush = () => { if (pushEnabled) { localStorage.setItem('eco_push_enabled', 'false'); setPushEnabled(false); showToast("Notifications web désactivées.", "info"); } else { if (!window.Notification) return showToast("Votre navigateur ne supporte pas les notifications.", "error"); Notification.requestPermission().then(perm => { if (perm === 'granted') { localStorage.setItem('eco_push_enabled', 'true'); setPushEnabled(true); showToast("Notifications activées !", "success"); } else { showToast("Vous avez bloqué les notifications dans votre navigateur.", "error"); } }); } }; const enableTotp = async () => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/setup_totp', { method: 'POST' }); const d = await res.json(); if (d.status === 'success') { setTotpSetupData(d.data); } else { showToast(d.message, 'error'); } } catch(e) { showToast("Erreur réseau", "error"); } setIsSaving(false); }; const confirmTotp = async () => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update_2fa', { method: 'POST', body: JSON.stringify({ method: 'totp' }) }); if ((await res.json()).status === 'success') { setProfileData({...profileData, two_factor_method: 'totp'}); setTotpSetupData(null); showToast("2FA par application activée avec succès !", "success"); } } catch(e) {} setIsSaving(false); }; const handleVaultUpload = async (files) => { if (!files || files.length === 0) return; let currentTotalSize = managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0; setIsSaving(true); let newDocs = [...(managingPanneau.privateDocs || [])]; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.size > 100 * 1024 * 1024) { showToast(`Le fichier ${file.name} dépasse 100 Mo.`, "error"); continue; } if (currentTotalSize + file.size > 500 * 1024 * 1024) { showToast("La limite totale de 500 Mo du coffre-fort serait dépassée.", "error"); break; } try { const type = file.type.includes('pdf') ? 'pdf' : 'image'; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'upload/file', { method: 'POST', body: (() => { const fd = new FormData(); fd.append('file', file); fd.append('type', type); fd.append('is_private', 'true'); return fd; })() }); const d = await res.json(); if (d.status === 'success') { newDocs.push({ id: d.data.fileId, type, name: file.name, date: new Date().toLocaleDateString('fr-FR'), size: d.data.size || file.size }); currentTotalSize += (d.data.size || file.size); } else showToast(d.message, "error"); } catch(err) { showToast("Erreur upload", "error"); } } const updatedPanneau = {...managingPanneau, privateDocs: newDocs}; setManagingPanneau(updatedPanneau); await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) }); refreshData(); setIsSaving(false); }; const deleteVaultDoc = (idx) => { setConfirmDialog({ title: "Supprimer la preuve", message: "Voulez-vous vraiment supprimer ce document privé ?", isDestructive: true, confirmText: "Supprimer", onConfirm: async () => { const newDocs = [...managingPanneau.privateDocs]; newDocs.splice(idx, 1); const updatedPanneau = {...managingPanneau, privateDocs: newDocs}; setManagingPanneau(updatedPanneau); setIsSaving(true); await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) }); refreshData(); setIsSaving(false); } }); }; const allSimpOptionsActive = data.settings?.simp_opt_description === '1' && data.settings?.simp_opt_image === '1' && data.settings?.simp_opt_theme === '1' && data.settings?.simp_opt_link === '1' && data.settings?.simp_opt_emergency === '1' && data.settings?.simp_opt_schedule === '1'; const isAdvancedMode = uiMode === 'professionnel' || allSimpOptionsActive; const renderDashboard = () => (

Bienvenue, {myClientData.name || 'Client'}

Gérez vos obligations légales BTP en toute simplicité.

{!isSuspended && !isCollaborator && purchasesAllowed && ( )}
{(!purchasesAllowed) && (

La création de nouvelles commandes est temporairement suspendue.

)} {isSuspended && (

Facture impayée - compte suspendu

L'accès à l'édition de vos panneaux est temporairement bloqué. Veuillez régulariser votre situation dans l'onglet Factures.

{!isCollaborator && }
)}
} color="text-emerald-600" bg="bg-emerald-100" onClick={() => setActiveTab('panels')} /> } color="text-slate-600" bg="bg-slate-200" onClick={() => setActiveTab('panels')} /> } color="text-blue-600" bg="bg-blue-100" /> } color="text-amber-600" bg="bg-amber-100" onClick={() => setActiveTab('messages')} />
{data.stats?.history && data.stats.history.length > 0 && (

Activité des riverains (30 derniers jours)

)}
); const renderPanels = () => (

Mes panneaux

Gestion de vos affichages légaux.

{!isSuspended && !isCollaborator && purchasesAllowed && ( )}
{(!purchasesAllowed) && (

La création de nouvelles commandes est temporairement suspendue.

)}
{data.panneaux?.map((c, i) => (

{c.name || 'Brouillon sans nom'}

{c.location || 'Localisation non définie'}

{c.status}
{c.status === 'Actif' && (

Scans QR

{c.views}

Abonnement

{c.offerType === 'rental' ? 'Location' : 'Achat'}

)}
{c.status === 'Actif' && ( <> {!isSuspended && ( )} {!isCollaborator && ( )} )} {c.status === 'Brouillon' && !isSuspended && ( <> {!isCollaborator && } )} {!isCollaborator && purchasesAllowed && ( )}
{(!isCollaborator && data.team?.length > 0 && isAdvancedMode) && (
Géré par :
)} {isCollaborator && (
Assigné à vous
)}
))} {data.panneaux?.length === 0 && (

Aucun panneau

{isCollaborator ? "Aucun panneau ne vous est assigné." : "Créez votre premier panneau pour démarrer."}

)}
); const renderLivraisons = () => { const deliveries = data.panneaux?.filter(c => c.physicalPanels > 0) || []; const activeDeliveries = deliveries.filter(c => c.shipping_status !== 'Livré'); const historyDeliveries = deliveries.filter(c => c.shipping_status === 'Livré'); const DeliveryCard = ({ c, isHistory }) => { const isShipped = c.shipping_status === 'Expédié'; const isWaiting = c.shipping_status === 'Validation en cours' || c.shipping_status === "Attente d'impression"; const displayStatus = c.shipping_status === "Attente d'impression" ? "Validée (en attente d'impression)" : c.shipping_status; return (
{isHistory ? : }

{c.name}

{c.shippingAddress || c.location}

Quantité

{c.physicalPanels}

Statut de la commande

{displayStatus}
{c.tracking_number && (

N° de suivi : {c.tracking_number}

{c.tracking_link && ( Suivre mon colis sur le site du transporteur )}
)} {isWaiting && (

Votre commande est en cours de traitement par nos équipes. Vous recevrez un lien de suivi dès l'expédition.

)}
{isShipped && !isHistory && ( )}
); }; return (

Livraisons

Suivi de vos commandes de panneaux physiques.

{deliveries.length === 0 ? (

Aucune livraison

Vous n'avez pas encore commandé de panneaux physiques.

) : ( <> {activeDeliveries.length > 0 && (

En cours ({activeDeliveries.length})

{activeDeliveries.map(c => )}
)} {historyDeliveries.length > 0 && (

Historique ({historyDeliveries.length})

{historyDeliveries.map(c => )}
)} )}
); }; const renderMessages = () => { const supportThreadId = `SUPPORT_${myClientData.id}`; const threadsMap = { [supportThreadId]: { id: supportThreadId, title: "Support technique eco-panneau", targetEmail: 'Admin', messages: [], unread: 0, isSupport: true } }; if (isCollaborator) delete threadsMap[supportThreadId]; interactions.forEach(m => { if (m.panneauId === supportThreadId && !isCollaborator) { threadsMap[supportThreadId].messages.push(m); if (m.author === 'Admin' && !m.resolved) threadsMap[supportThreadId].unread++; } else if (m.panneauId !== supportThreadId) { const riverainEmail = (m.author !== 'Client' && m.author !== 'Admin') ? m.author : m.target; if (!riverainEmail) return; const threadId = `${m.panneauId}_${riverainEmail}`; const panneauName = activePanneaux.find(c => c.id === m.panneauId)?.name || draftPanneaux.find(c => c.id === m.panneauId)?.name || 'Panneau inconnu'; if (!threadsMap[threadId]) { threadsMap[threadId] = { id: threadId, panneauId: m.panneauId, title: `${panneauName} - ${riverainEmail}`, targetEmail: riverainEmail, messages: [], unread: 0, isSupport: false }; } threadsMap[threadId].messages.push(m); if (m.author !== 'Client' && !m.resolved) threadsMap[threadId].unread++; } }); const threads = Object.values(threadsMap) .filter(t => t.isSupport || t.messages.length > 0) .sort((a,b) => { // CORRECTION : Contournement du bug Date Safari avec le wrapper String const dateA = a.messages.length > 0 ? new Date(String(a.messages[0].created_at || '').replace(' ', 'T')).getTime() : 0; const dateB = b.messages.length > 0 ? new Date(String(b.messages[0].created_at || '').replace(' ', 'T')).getTime() : 0; return dateB - dateA; }); const requestedChatId = new URLSearchParams(window.location.search).get('chat_id'); const selectedThreadId = requestedChatId || (threads[0]?.id); const setChatId = (id) => { const u = new URL(window.location); u.searchParams.set('chat_id', id); window.history.replaceState({}, '', u); setMobileChatView(true); refreshData(); }; const selectedThread = threads.find(t => t.id === selectedThreadId); const handleSendClient = async (text) => { if (!selectedThread) return; const optimisticMsg = { id: 'temp_' + Date.now(), panneauId: selectedThread.isSupport ? selectedThread.id : selectedThread.panneauId, author: 'Client', target: selectedThread.targetEmail, detail: text, isAlert: 0, resolved: 0, created_at: new Date().toISOString() }; window.dispatchEvent(new CustomEvent('optimistic_message', { detail: optimisticMsg })); const payload = { panneauId: selectedThread.isSupport ? selectedThread.id : selectedThread.panneauId, detail: text, author: 'Client', targetEmail: selectedThread.targetEmail }; fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions', { method: 'POST', body: JSON.stringify(payload) }); }; const forceChatMobile = mobileChatView || !!requestedChatId || threads.length === 1; return (

Conversations

{threads.map(t => ( ))} {threads.length === 0 &&

Aucune conversation.

}
{selectedThread ? ( <>
{threads.length > 1 && ( )}

{selectedThread.title}

) : (

Sélectionnez une conversation

)}
); }; const renderBilling = () => (

Factures

Historique comptable et paiements.

Export CSV
{myClientData.wallet_balance > 0 && (

Solde disponible (Avoir / Crédit)

Ce montant sera automatiquement déduit de vos prochains achats ou abonnements.

{myClientData.wallet_balance.toFixed(2)} €
)}
{data.invoices?.map((inv, i) => ( {/* CORRECTION : Contournement du bug Date Safari avec le wrapper String */} ))}
DatePanneauDétailMontantPDF
{new Date(String(inv.created_at || '').replace(' ', 'T')).toLocaleDateString('fr-FR')} {inv.panneauName} {inv.type} {inv.amount} €
{data.invoices?.length === 0 &&
Aucune facture pour le moment.
}
); const renderAccount = () => (

Mon compte

Paramètres de profil et sécurité.

Profil de la société

{!isCollaborator && ( )}
{!isCollaborator && (
)}
setProfileData({...profileData, name: e.target.value})} disabled={isCollaborator} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition disabled:bg-slate-50" />
{profileData.type_client === 'entreprise' && ( <>
setProfileData({...profileData, siret: e.target.value})} disabled={isCollaborator} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition disabled:bg-slate-50" />
{!isCollaborator && ( )} {!isCollaborator && (
{hasTva && (
setProfileData({...profileData, tva: e.target.value})} placeholder="FR..." className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition uppercase" />
)}
)} )} {!isCollaborator && ( )} {!isCollaborator && }

Sécurité

setPasswordData({...passwordData, oldPassword: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition" />
setPasswordData({...passwordData, newPassword: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition" placeholder="Min 8 car., Maj, Min, Chiffre" />

Authentification à double facteur (2FA)

Protégez votre compte avec une étape de sécurité supplémentaire lors de la connexion.

{!isCollaborator && isAdvancedMode && (

Équipe de collaborateurs

Gérez les accès de votre équipe à vos panneaux.

)} {/* Bloc Documents Légaux */}

Documents légaux

Consultez nos conditions d'utilisation et notre politique de confidentialité.

Alertes navigateur

Recevez une alerte visuelle et sonore sur votre PC/Mobile dès qu'un riverain poste un message.

{!isCollaborator && (

Données personnelles (RGPD)

Téléchargez une archive complète de vos données personnelles (profil, factures, panneaux, D.O.E, pièces jointes) au format ZIP sécurisé.

)} {!isCollaborator && (

Zone de danger

La suppression de votre compte effacera définitivement tous vos panneaux, factures et fichiers (D.O.E inclus).

)}
); return ( Mode avancé
handleUiModeChange(e.target.checked)} disabled={isSaving} /> ) : null} > {window.ECO_CONFIG.stripePubKey?.startsWith('pk_test_') && (
Mode Test Stripe Actif - Aucun débit réel ne sera effectué lors de vos transactions.
)} {isLockedForClient && (

Télémaintenance en cours

L'équipe technique intervient sur votre compte. Vos actions sont temporairement restreintes à la messagerie.

)} {isImpersonating && (

Mode administrateur actif

Vous contrôlez actuellement le compte de {myClientData.name}. N'oubliez pas de clôturer l'intervention une fois terminé.

)} {(!isLockedForClient && activeTab === 'dashboard') && renderDashboard()} {(!isLockedForClient && activeTab === 'panels') && renderPanels()} {(!isLockedForClient && activeTab === 'livraisons') && renderLivraisons()} {activeTab === 'messages' && renderMessages()} {(!isLockedForClient && activeTab === 'billing') && renderBilling()} {(!isLockedForClient && activeTab === 'account') && renderAccount()} {isModalOpen && modalStep.startsWith('config') && (
{ if(e.target === e.currentTarget) document.getElementById('editor-cancel-btn')?.click(); }}>
e.stopPropagation()}>

{managingPanneau.id ? 'Édition du panneau' : 'Nouveau panneau'}

Assurez-vous de l'exactitude des informations légales.

{validationErrors.length > 0 && (

Impossible de publier. Éléments bloquants :

    {validationErrors.map((err, idx) =>
  • {err}
  • )}
)}
setIsModalOpen(false)} onSaveDraft={() => handleSavePanneau(true)} onPublish={ isCollaborator ? (managingPanneau.status === 'Actif' ? () => { const errs = validatePanel(); if (errs.length > 0) setValidationErrors(errs); else handleSavePanneau(false); } : undefined) : () => { const errs = validatePanel(); if (errs.length > 0) { setValidationErrors(errs); } else { setValidationErrors([]); // CALCULER S'IL S'AGIT D'UN NOUVEL ACHAT/UPGRADE SUR UN PANNEAU DEJA ACTIF const original = data.panneaux.find(p => p.id === managingPanneau.id); const upgrading = original && original.status === 'Actif' && ( (original.offerType === 'rental' && managingPanneau.offerType === 'purchase') || (!original.hasNoAds && managingPanneau.hasNoAds) || (managingPanneau.physicalPanels > original.physicalPanels) ); if (managingPanneau.status === 'Brouillon' || upgrading) { setWaiverAccepted(false); setModalStep('select_offer'); } else { handleSavePanneau(false); } } } } onPreview={() => setPreviewPanneau(managingPanneau)} onEditEntity={(loc, data) => setEditingEntity({location: loc, data})} draggedItem={draggedItem} handleDragStart={handleDragStart} handleDragOver={handleDragOver} handleDrop={handleDrop} deleteEntity={deleteEntity} isSaving={isSaving} uiMode={uiMode} settings={data.settings} validationErrors={validationErrors} />
{editingEntity && }
)} {isModalOpen && modalStep === 'select_offer' && (

Choix de l'offre

Sélectionnez le type d'abonnement et les options matérielles.

setManagingPanneau({...managingPanneau, offerType: 'rental'})} className={`p-6 rounded-2xl border-2 cursor-pointer transition flex flex-col justify-between ${currentOfferType === 'rental' ? 'border-emerald-500 bg-emerald-50 shadow-md' : 'border-slate-200 hover:border-emerald-200'} ${(isActif && originalOfferType === 'purchase') ? 'opacity-50 pointer-events-none' : ''}`} >

Abonnement mensuel

{pRental} € HT / mois

Paiement mensuel automatisé. Résiliable à tout moment, en un clic, à la fin de vos travaux. Engagement mensuel, tout mois entamé est dû.

setManagingPanneau({...managingPanneau, offerType: 'purchase'})} className={`p-6 rounded-2xl border-2 cursor-pointer transition flex flex-col justify-between ${currentOfferType === 'purchase' ? 'border-emerald-500 bg-emerald-50 shadow-md' : 'border-slate-200 hover:border-emerald-200'}`} >

Achat définitif

{pPurchase} € HT

Paiement unique sans abonnement. Le panneau numérique reste actif indéfiniment.

Commander des panneaux physiques

Recevez votre panneau pré-imprimé (Format A1) sur un support rigide Akilux résistant aux intempéries, livré directement au cabinet.

Quantité souhaitée

{pBoardFirst}€ HT le premier, {pBoardAdd}€ HT l'unité supplémentaire.

{currentPanels}
{currentPanels > 0 && (

Laissez vide pour utiliser l'adresse de votre société.

)}
{/* Résumé en temps réel */}

Résumé de votre commande

{!isActif && ( <>
{currentOfferType === 'rental' ? 'Abonnement mensuel (1er mois inclus)' : 'Achat définitif'}{(currentOfferType === 'rental' ? pRental : pPurchase).toFixed(2)} € HT
{currentOfferType === 'purchase' &&
Hébergement annuel (1ère année offerte)0.00 € HT {pHosting.toFixed(2)} €
} {currentPanels > 0 &&
Panneaux physiques ({currentPanels}x){(currentPanels > 0 ? pBoardFirst + (currentPanels - 1) * pBoardAdd : 0).toFixed(2)} € HT
} {currentHasNoAds &&
Option marque blanche (Sans mentions de eco-panneau.fr){pNoAds.toFixed(2)} € HT
} )} {isActif && ( <> {originalOfferType === 'rental' && currentOfferType === 'purchase' &&
Passage à l'achat définitif{pPurchase.toFixed(2)} € HT
} {currentPanels > originalPanels &&
Panneaux physiques supp. ({currentPanels - originalPanels}x){addedPanelsCost.toFixed(2)} € HT
} {currentHasNoAds && !originalHasNoAds &&
Option marque blanche (Sans mentions de eco-panneau.fr){pNoAds.toFixed(2)} € HT
} )} {discountPct > 0 && upfrontHT > 0 && (
Remise client ({discountPct}%) -{(upfrontHT * (discountPct / 100)).toFixed(2)} € HT
)} {/* NOUVEAU : Affichage de la déduction de la cagnotte */} {myClientData.wallet_balance > 0 && upfrontHT > 0 && (
Payé avec votre solde (Avoir) -{Math.min(finalUpfrontTTC, myClientData.wallet_balance).toFixed(2)} € TTC
)}

Reste à payer aujourd'hui

{platformHasTva ? 'TVA 20% incluse' : 'TVA non applicable'}

{Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)).toFixed(2)} €

{currentOfferType === 'rental' &&

Puis {finalMonthlyTTC.toFixed(2)} € TTC / mois

} {currentOfferType === 'purchase' &&

Puis { (pHosting * priceMultiplier * tvaMult).toFixed(2) } € TTC / an

}
{(!purchasesAllowed && Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)) > 0) && (

L'administration a temporairement suspendu la validation de nouvelles commandes payantes sur la plateforme. Veuillez réessayer ultérieurement.

)} {Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)) > 0 && purchasesAllowed && ( )}
)} {isModalOpen && modalStep === 'payment' && paymentData && ( setIsModalOpen(false)} preventClose={isSaving}> setIsModalOpen(false)} /> )} {/* MODALE DE CONFIGURATION TOTP 2FA */} {totpSetupData && ( { setTotpSetupData(null); setProfileData({...profileData, two_factor_method: 'none'}); }} zIndex="z-[250]">

Scannez ce QR Code avec votre application d'authentification (Google Authenticator, Authy, etc.) :

Ou saisissez cette clé manuellement :

{totpSetupData.secret}
)} {/* GESTIONNAIRE DE LIENS DÉLÉGUÉS */} {isModalOpen && modalStep === 'share' && ( setIsModalOpen(false)}>
Créez des liens sécurisés pour confier la saisie d'informations ou le dépôt de documents (PDF) à d'autres membres de l'équipe du chantier sans leur donner accès à votre compte ou à votre facturation.
{/* Formulaire de création */}

Créer un nouveau lien

setNewLink({...newLink, name: e.target.value})} placeholder="Ex: Architecte Dupont" className="w-full border border-slate-200 rounded-lg p-2 text-sm outline-none focus:border-blue-500" />
setNewLink({...newLink, expDays: parseInt(e.target.value)||7})} className="w-full border border-slate-200 rounded-lg p-2 text-sm outline-none focus:border-blue-500" />
{[ { id: 'name', label: 'Nom du chantier' }, { id: 'location', label: 'Lieu' }, { id: 'maitreOuvrage', label: "Maître d'ouvrage" }, { id: 'permitNumber', label: 'N° de permis' }, { id: 'description', label: 'Description' }, { id: 'promoterLink', label: 'Lien promoteur' }, { id: 'emergencyPhone', label: 'N° d\'urgence' }, { id: 'noiseSchedule', label: 'Horaires' }, { id: 'intervenants', label: 'Intervenants' }, { id: 'lots', label: 'Lots' }, { id: 'pdfId', label: 'Arrêté (PDF)' } ].map(f => ( ))}
{/* Liste des liens existants */}

Liens générés pour ce panneau

{(!managingPanneau.delegate_links || managingPanneau.delegate_links.length === 0) && (

Aucun lien n'a encore été créé pour ce panneau.

)} {managingPanneau.delegate_links?.map((link, idx) => { const isExpired = link.exp < Date.now() / 1000; return (

{link.name}

Exp. : {new Date(link.exp * 1000).toLocaleDateString('fr-FR')} {isExpired && (Expiré)}

{link.lockedFields && link.lockedFields.length > 0 && (

Bloqué : {link.lockedFields.join(', ')}

)}
); })}
)} {isModalOpen && modalStep === 'legal_vault' && ( setIsModalOpen(false)} preventClose={isSaving}>

Preuves et constats d'huissier

Ces documents sont chiffrés et inaccessibles au public. Ils seront inclus dans l'archive D.O.E lors de la clôture du panneau.

{/* Zone de Drag et Drop pour le Coffre-Fort */}
{ e.preventDefault(); setVaultDragging(true); }} onDragLeave={(e) => { e.preventDefault(); setVaultDragging(false); }} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); setVaultDragging(false); const handleVaultUpload = async (files) => { if (!files || files.length === 0) return; let currentTotalSize = managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0; setIsSaving(true); let newDocs = [...(managingPanneau.privateDocs || [])]; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.size > 100 * 1024 * 1024) { showToast(`Le fichier ${file.name} dépasse 100 Mo.`, "error"); continue; } if (currentTotalSize + file.size > 500 * 1024 * 1024) { showToast("La limite totale de 500 Mo du coffre-fort serait dépassée.", "error"); break; } try { const type = file.type.includes('pdf') ? 'pdf' : 'image'; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'upload/file', { method: 'POST', body: (() => { const fd = new FormData(); fd.append('file', file); fd.append('type', type); fd.append('is_private', 'true'); return fd; })() }); const d = await res.json(); if (d.status === 'success') { newDocs.push({ id: d.data.fileId, type, name: file.name, date: new Date().toLocaleDateString('fr-FR'), size: d.data.size || file.size }); currentTotalSize += (d.data.size || file.size); } else showToast(d.message, "error"); } catch(err) { showToast("Erreur upload", "error"); } } const updatedPanneau = {...managingPanneau, privateDocs: newDocs}; setManagingPanneau(updatedPanneau); await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) }); refreshData(); setIsSaving(false); }; handleVaultUpload(e.dataTransfer.files); }} className={`w-full min-h-[150px] border-2 border-dashed rounded-xl flex flex-col items-center justify-center p-6 transition relative ${vaultDragging ? 'border-emerald-500 bg-emerald-50/50' : 'border-slate-300 bg-slate-50 hover:bg-slate-100'}`} title="Vous pouvez également coller vos fichiers (Ctrl+V) directement sur cette fenêtre." > {isSaving ? (
Chiffrement en cours...
) : ( <>
Glissez-déposez vos preuves ici (ou Ctrl+V) Images ou PDF (Max 100 Mo/fichier, 500 Mo au total) )}
{/* Affichage Vignettes du Coffre-Fort */} {managingPanneau.privateDocs && managingPanneau.privateDocs.length > 0 ? (
{managingPanneau.privateDocs.map((doc, idx) => (
setViewingDoc(doc)}>
{doc.type === 'image' ? ( aperçu ) : (
PDF
)}

{doc.name}

{doc.date}
))}
) : (

Aucun document privé stocké.

)}
Total utilisé : {((managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0) / (1024*1024)).toFixed(2)} Mo / 500 Mo
)} {/* Visualiseur Plein Écran Coffre-Fort (Remplacement Iframe -> PdfFullViewer) */} {viewingDoc && (
setViewingDoc(null)}>
e.stopPropagation()}>
{viewingDoc.name} Télécharger
{viewingDoc.type === 'image' ? ( Aperçu e.stopPropagation()} /> ) : ( )}
)} {/* Modale de Confirmation de Livraison */} {deliveryConfirmModal && ( setDeliveryConfirmModal(null)} preventClose={isSaving}>

Colis livré ?

En confirmant, vous indiquez avoir bien reçu l'intégralité de votre commande de panneaux physiques pour ce panneau.

)} {/* MODALE DOCUMENTS LÉGAUX (CGV / RGPD) */} {legalModal && ( setLegalModal(null)} zIndex="z-[250]">
/g, '>').replace(/"/g, '"').replace(/'/g, ''') }} className="text-sm text-slate-600 leading-relaxed font-medium admin-html" /> )} {/* Modales de Confirmation et Prompt Universelles */} {confirmDialog && setConfirmDialog(null)} />} {promptDialog && setPromptDialog(null)} />} {previewPanneau && setPreviewPanneau(null)} showToast={showToast} refreshData={refreshData} />} {/* MODALE GLOBALE DE GENERATION D'ARCHIVES */} {archiveConfig && ( setArchiveConfig(null)} showToast={showToast} refreshData={refreshData} /> )} ); }; /* EOF ===== [_clients.jsx] =============== */