/** * ========================================================================= * PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0 * Interface Client B2B (Tableau de bord, Facturation, Gestion des panneaux) * ========================================================================= */ const { useState, useEffect } = React; const { Home, Building, MessageSquare, FileText, UserCircle, Plus, Search, CheckCircle, AlertTriangle, Shield, Copy, Archive, Trash2, Edit, Eye, CreditCard, Download, Loader, LogOut, ArrowLeft, KeyRound, Lock, ShieldCheck, Mail, Users, Bell, Power, Settings, FileCheck, Image, X, Zap, Package, Share2, RefreshCw, MapPin, ExternalLink } = window; // ========================================================================= // 1. COMPOSANT STRIPE : FORMULAIRE DE PAIEMENT // ========================================================================= const StripePaymentForm = ({ clientSecret, onSuccess, onCancel, amountCents }) => { const stripe = window.Stripe(window.ECO_CONFIG.stripePubKey); const options = { clientSecret, appearance: { theme: 'stripe', variables: { colorPrimary: '#10b981', borderRadius: '12px' } } }; 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 [managingChantier, setManagingChantier] = useState(null); const [editingEntity, setEditingEntity] = useState(null); const [draggedItem, setDraggedItem] = useState(null); const [isSaving, setIsSaving] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const [modalStep, setModalStep] = useState('config_full'); const [validationErrors, setValidationErrors] = useState([]); const [delegateLink, setDelegateLink] = useState(''); const [paymentData, setPaymentData] = useState(null); const [previewChantier, setPreviewChantier] = useState(null); const [profileData, setProfileData] = useState({ name: '', address: '', siret: '', tva: '', monthlyReport: 0 }); const [hasTva, setHasTva] = useState(true); const [showTvaWarning, setShowTvaWarning] = useState(false); const [passwordData, setPasswordData] = useState({ oldPassword: '', newPassword: '' }); const [teamEmail, setTeamEmail] = useState(''); 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; useEffect(() => { if (myClientData.uiMode) setUiMode(myClientData.uiMode); const isTvaExempt = myClientData.tva === 'NON_ASSUJETTI'; setHasTva(!isTvaExempt); setProfileData({ name: myClientData.name || '', address: myClientData.address || '', siret: myClientData.siret || '', tva: isTvaExempt ? '' : (myClientData.tva || ''), monthlyReport: myClientData.monthlyReport || 0 }); }, [myClientData]); // Interception du lien magique de validation de réception (depuis l'e-mail logistique) useEffect(() => { const url = new URL(window.location); const confirmDelivery = url.searchParams.get('confirm_delivery'); if (confirmDelivery) { setActiveTab('livraisons'); if (window.confirm("Avez-vous bien reçu vos panneaux pour ce chantier ?")) { setIsSaving(true); fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/shipping', { method: 'POST', body: JSON.stringify({ id: confirmDelivery, shipping_status: 'Livré' }) }).then(res => res.json()).then(d => { if (d.status === 'success') { showToast("Réception confirmée, merci !"); refreshData(); } }).finally(() => setIsSaving(false)); } url.searchParams.delete('confirm_delivery'); window.history.replaceState({}, '', url); } }, []); const activeChantiers = data.chantiers?.filter(c => c.status === 'Actif') || []; const draftChantiers = data.chantiers?.filter(c => c.status === 'Brouillon') || []; const interactions = data.interactions || []; const totalUnread = interactions.filter(m => !m.resolved && m.author !== 'Client').length; const activeDeliveries = data.chantiers?.filter(c => c.physicalPanels > 0 && c.shipping_status !== 'Livré') || []; 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 customLogout = isImpersonating ? { label: "Retour Admin", icon: , action: () => handleUnimpersonate() } : null; const navItems = [ { id: 'dashboard', icon: , label: 'Tableau de bord', badge: 0 }, { id: 'panels', icon: , label: 'Mes chantiers', 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 }, ]; const saveEditedEntity = () => { let newInter = [...(managingChantier.intervenants || [])]; let newLots = JSON.parse(JSON.stringify(managingChantier.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()}); } setManagingChantier({ ...managingChantier, intervenants: newInter, lots: newLots }); setEditingEntity(null); }; const deleteEntity = (loc) => { if (!confirm("Supprimer cet élément ?")) return; let newInter = [...(managingChantier.intervenants || [])]; let newLots = JSON.parse(JSON.stringify(managingChantier.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); setManagingChantier({ ...managingChantier, 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 = [...(managingChantier.intervenants || [])]; let newLots = JSON.parse(JSON.stringify(managingChantier.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); } setManagingChantier({ ...managingChantier, intervenants: newInter, lots: newLots }); setDraggedItem(null); }; const validatePanel = () => { const errors = []; if (!managingChantier.name?.trim()) errors.push("Le nom du chantier est manquant."); if (!managingChantier.location?.trim()) errors.push("L'adresse du chantier est manquante."); if (!managingChantier.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 manquante (onglet Mon compte)."); if (!profileData.address?.trim()) errors.push("Facturation : adresse postale manquante (onglet Mon compte)."); if (!profileData.siret?.trim()) errors.push("Facturation : SIRET de l'entreprise manquant (onglet Mon compte)."); } return errors; }; const handleSaveChantier = async (forceDraft = false) => { if (!forceDraft && managingChantier.status === 'Actif') { const errs = validatePanel(); if (errs.length > 0) { setValidationErrors(errs); return; } } setIsSaving(true); try { const payload = { id: managingChantier.id, status: managingChantier.status, offerType: managingChantier.offerType, currentRate: managingChantier.currentRate, physicalPanels: managingChantier.physicalPanels, details: { ...managingChantier, name: managingChantier.name?.trim() || 'Brouillon sans nom', location: managingChantier.location?.trim() || '' } }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers', { method: 'POST', body: JSON.stringify(payload) }); const d = await res.json(); if(d.status === 'success') { showToast(forceDraft ? "Brouillon sauvegardé !" : "Chantier 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"); setIsSaving(true); try { const payload = { chantier_id: managingChantier.id, offer_type: managingChantier.offerType, physical_panels: managingChantier.physicalPanels, hasNoAds: managingChantier.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 + 'chantiers/activate_free', { method: 'POST', body: JSON.stringify({ ...payload, id: managingChantier.id, offerType: managingChantier.offerType, physicalPanels: managingChantier.physicalPanels, details: managingChantier, name: managingChantier.name }) }); if((await freeRes.json()).status === 'success') { showToast("Activation gratuite 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 Stripe", "error"); } setIsSaving(false); }; const handlePaymentSuccess = async (pi_id) => { setIsSaving(true); try { const payload = { payment_intent_id: pi_id, subscription_id: paymentData?.subId, id: managingChantier.id, offerType: managingChantier.offerType, currentRate: managingChantier.currentRate, physicalPanels: managingChantier.physicalPanels, details: managingChantier, name: managingChantier.name }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/activate_paid', { method: 'POST', body: JSON.stringify(payload) }); const d = await res.json(); if(d.status === 'success') { showToast("Paiement validé ! Panneau activé."); setIsModalOpen(false); refreshData(); } else showToast(d.message, 'error'); } catch(e) { showToast("Erreur", "error"); } setIsSaving(false); }; const generateDelegateLink = async (id) => { try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/delegate_link', { method: 'POST', body: JSON.stringify({ id }) }); const d = await res.json(); if(d.status === 'success') { setDelegateLink(d.data.link); setModalStep('share'); setIsModalOpen(true); } else showToast(d.message, 'error'); } catch(e) { showToast("Erreur réseau", "error"); } }; const handleDeleteTeamMember = async (uid) => { if (!confirm("Voulez-vous vraiment supprimer ce collaborateur ? Ses accès seront révoqués et ses panneaux vous seront réassignés.")) return; 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 (chantierId, assignedUid) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/assign', { method: 'POST', body: JSON.stringify({ chantier_id: chantierId, assigned_uid: assignedUid }) }); if ((await res.json()).status === 'success') { showToast("Assignation mise à jour."); refreshData(); } } finally { setIsSaving(false); } }; const handleAutoFill = async () => { const num = prompt("Saisissez votre numéro SIRET (14 chiffres) ou SIREN (9 chiffres) :"); if (!num) return; 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 : Aucune entreprise correspondante.", "error"); } const siege = company.siege || {}; const siren = company.siren; let foundSiret = siege.siret || 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(siren, 10) % 97)) % 97; const calculatedTva = `FR${tvaKey.toString().padStart(2, '0')}${siren}`; setProfileData({ ...profileData, name: company.nom_complet, address: address, siret: foundSiret, tva: calculatedTva }); setHasTva(true); showToast("Informations récupérées avec succès !", "success"); } else { showToast("Aucune entreprise trouvée.", "error"); } } catch (err) { console.error("Détail de l'erreur SIRENE :", err); showToast("Le service gouvernemental est momentanément 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() || !profileData.siret.trim()) { return showToast("Veuillez remplir le nom, l'adresse et le SIRET.", "error"); } let cleanTva = 'NON_ASSUJETTI'; if (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"); if (cleanTva.startsWith('FR')) { if (!/^FR[0-9A-Z]{2}[0-9]{9}$/.test(cleanTva)) return showToast("Format de TVA française invalide.", "error"); if (profileData.siret && profileData.siret.length >= 9) { const sirenBase = profileData.siret.replace(/\s+/g, '').substring(0, 9); const tvaSiren = cleanTva.substring(4); if (sirenBase !== tvaSiren) return showToast("La TVA ne correspond pas au SIRET.", "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: hasTva ? cleanTva : '' }); showToast("Profil mis à jour et validé !"); refreshData(); } } finally { setIsSaving(false); } }; const handleUiModeChange = async (isAdvanced) => { const newMode = isAdvanced ? 'professionnel' : 'simplifie'; setUiMode(newMode); setIsSaving(true); try { let cleanTva = 'NON_ASSUJETTI'; if (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 lors de la sauvegarde du mode", "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 = async () => { const pwd = prompt("Pour confirmer la suppression de votre compte, veuillez saisir votre mot de passe :"); if (!pwd) return; 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 sidebarFooter = !isCollaborator ? ( ) : null; const renderDashboard = () => (

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

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

{!isSuspended && !isCollaborator && ( )}
{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')} />

Fréquentation (30j)

Activité récente

{interactions.slice(0, 5).map((m, i) => (
setActiveTab('messages')} className="flex items-start gap-3 p-3 rounded-xl hover:bg-slate-50 cursor-pointer transition border border-transparent hover:border-slate-100">
{m.isAlert ? : }

{m.author}

{m.detail}

))} {interactions.length === 0 &&

Aucune activité récente.

}
); const renderPanels = () => (

Mes chantiers

Gestion de vos affichages légaux.

{!isSuspended && !isCollaborator && }
{data.chantiers?.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 && ( )}
{(!isCollaborator && data.team?.length > 0 && uiMode === 'professionnel') && (
Géré par :
)} {isCollaborator && (
Assigné à vous
)}
))} {data.chantiers?.length === 0 && (

Aucun chantier

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

{!isSuspended && !isCollaborator && }
)}
); const renderLivraisons = () => { const deliveries = data.chantiers?.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.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.chantierId === supportThreadId && !isCollaborator) { threadsMap[supportThreadId].messages.push(m); if (m.author === 'Admin' && !m.resolved) threadsMap[supportThreadId].unread++; } else if (m.chantierId !== supportThreadId) { const riverainEmail = (m.author !== 'Client' && m.author !== 'Admin') ? m.author : m.target; if (!riverainEmail) return; const threadId = `${m.chantierId}_${riverainEmail}`; const panelName = activeChantiers.find(c => c.id === m.chantierId)?.name || draftChantiers.find(c => c.id === m.chantierId)?.name || 'Panneau inconnu'; if (!threadsMap[threadId]) { threadsMap[threadId] = { id: threadId, chantierId: m.chantierId, title: `${panelName} - ${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) => { const dateA = a.messages.length > 0 ? new Date(a.messages[0].created_at) : new Date(0); const dateB = b.messages.length > 0 ? new Date(b.messages[0].created_at) : new Date(0); return dateB - dateA; }); const selectedThreadId = new URLSearchParams(window.location.search).get('chat_id') || threads[0]?.id; const setChatId = (id) => { const u = new URL(window.location); u.searchParams.set('chat_id', id); window.history.replaceState({}, '', u); refreshData(); }; const selectedThread = threads.find(t => t.id === selectedThreadId); const handleSendClient = async (text) => { if (!selectedThread) return; const payload = { chantierId: selectedThread.isSupport ? selectedThread.id : selectedThread.chantierId, detail: text, author: 'Client', targetEmail: selectedThread.targetEmail }; await fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions', { method: 'POST', body: JSON.stringify(payload) }); refreshData(); }; return (

Conversations

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

Aucune conversation.

}
{selectedThread ? ( <>

{selectedThread.title}

) : (

Sélectionnez une conversation

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

Factures

Historique comptable et paiements.

Export CSV
{data.invoices?.map((inv, i) => ( ))}
DateChantierDétailMontantPDF
{new Date(inv.created_at).toLocaleDateString('fr-FR')} {inv.chantierName} {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 && ( )}
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" />
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 && (
{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" />
{!isCollaborator && uiMode === 'professionnel' && (

Équipe de collaborateurs

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

)}

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.

Exporter mes données
)} {!isCollaborator && (

Zone de danger

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

)}
); // Initialisation des variables pour le résumé temps réel du prix const pRental = data.prices?.rentalMo || 180; const pPurchase = data.prices?.purchase || 850; const pBoardFirst = data.prices?.boardFirst || 250; const pBoardAdd = data.prices?.boardAdd || 150; const pNoAds = data.prices?.noAds || 150; const currentOfferType = managingChantier?.offerType || 'rental'; const currentPanels = managingChantier?.physicalPanels || 0; const currentHasNoAds = managingChantier?.hasNoAds || false; let boardCostHT = 0; if (currentPanels > 0) { boardCostHT = pBoardFirst + (currentPanels - 1) * pBoardAdd; } let upfrontHT = boardCostHT + (currentHasNoAds ? pNoAds : 0); if (currentOfferType === 'rental') { upfrontHT += pRental; } else { upfrontHT += pPurchase; } 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; return ( Mode avancé
handleUiModeChange(e.target.checked)} disabled={isSaving} /> ) : null} > {activeTab === 'dashboard' && renderDashboard()} {activeTab === 'panels' && renderPanels()} {activeTab === 'livraisons' && renderLivraisons()} {activeTab === 'messages' && renderMessages()} {activeTab === 'billing' && renderBilling()} {activeTab === 'account' && renderAccount()} {/* Modale d'avertissement TVA */} {showTvaWarning && ( setShowTvaWarning(false)}>

Déclaration sur l'honneur

En désactivant ce champ, vous attestez sur l'honneur relever d'un régime d'exonération de TVA (ex: franchise en base de TVA des micro-entreprises - art. 293 B du CGI).

Veuillez noter que la plateforme eco-panneau.fr est tenue de vous facturer la TVA française (20%) sur ses services. En tant que non-assujetti, vous ne pourrez pas la récupérer fiscalement.

Toute fausse déclaration engage votre responsabilité exclusive vis-à-vis de l'administration fiscale.

)} {/* Modale de gestion d'équipe */} {modalStep === 'team_manage' && ( setModalStep('config_full')}>

Inviter un nouveau membre

setTeamEmail(e.target.value)} placeholder="E-mail du collaborateur" className="flex-1 border border-slate-200 rounded-lg p-2.5 text-sm outline-none focus:border-emerald-500 bg-white" />

Membres actuels

{data.team?.map((member, i) => (

{member.email}

))} {(!data.team || data.team.length === 0) &&

Aucun collaborateur dans votre équipe.

}
)} {/* Modales d'actions */} {isModalOpen && modalStep.startsWith('config') && (
{ if(e.target === e.currentTarget) document.getElementById('editor-cancel-btn')?.click(); }}>
e.stopPropagation()}>

{managingChantier.id ? 'Édition du chantier' : 'Nouveau chantier'}

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={() => handleSaveChantier(true)} onPublish={ isCollaborator ? (managingChantier.status === 'Actif' ? () => { const errs = validatePanel(); if (errs.length > 0) setValidationErrors(errs); else handleSaveChantier(false); } : undefined) : () => { const errs = validatePanel(); if (errs.length > 0) { setValidationErrors(errs); } else { setValidationErrors([]); if (managingChantier.status === 'Brouillon') { setModalStep('select_offer'); } else { handleSaveChantier(false); } } } } onPreview={() => setPreviewChantier(managingChantier)} 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.

{/* Option Abonnement Mensuel */}
setManagingChantier({...managingChantier, 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'}`} >

Abonnement mensuel

{pRental} € HT / mois

Paiement mensuel automatisé. Résiliable à tout moment, en un clic, à la fin de votre chantier.

{/* Option Achat Définitif */}
setManagingChantier({...managingChantier, 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}
{/* Résumé en temps réel */}

Résumé de votre commande

{currentOfferType === 'rental' ? 'Abonnement mensuel (1er mois inclus)' : 'Achat définitif'} {(currentOfferType === 'rental' ? pRental : pPurchase).toFixed(2)} € HT
{currentPanels > 0 && (
Panneaux physiques ({currentPanels}x) {boardCostHT.toFixed(2)} € HT
)} {currentHasNoAds && (
Option marque blanche {pNoAds.toFixed(2)} € HT
)} {discountPct > 0 && (
Remise client ({discountPct}%) -{(upfrontHT * (discountPct / 100)).toFixed(2)} € HT
)}

Total à payer aujourd'hui

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

{finalUpfrontTTC.toFixed(2)} €

{currentOfferType === 'rental' &&

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

}
)} {isModalOpen && modalStep === 'payment' && paymentData && ( setIsModalOpen(false)} preventClose={isSaving}> setIsModalOpen(false)} /> )} {isModalOpen && modalStep === 'share' && ( setIsModalOpen(false)}>

Envoyez ce lien sécurisé (valable 7 jours) à l'architecte ou au maître d'œuvre. Il pourra compléter l'équipe et déposer l'arrêté sans avoir accès à votre facturation.

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

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 chantier.

{managingChantier.privateDocs?.map((doc, idx) => (

{doc.name}

{doc.date}

))} {(!managingChantier.privateDocs || managingChantier.privateDocs.length === 0) &&

Aucun document privé stocké.

}
)} {previewChantier && setPreviewChantier(null)} showToast={showToast} refreshData={refreshData} />}
); }; /* EOF ===== [_clients.jsx] =============== */