/** * ========================================================================= * PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0 * Interface Administrateur - Onglets Panneaux, Logistique et Messagerie * ========================================================================= */ const { useState, useEffect } = React; const { Building, Eye, AlertOctagon, CheckCircle, AlertTriangle, Power, Package, MapPin, RefreshCw, Edit, FileText, Download, Plus, MessageSquare, ArrowLeft, KeyRound, ShieldAlert, Shield, Mail, Trash2, X, Loader } = window; // ========================================================================= // 1. ONGLET : TOUS LES PANNEAUX // ========================================================================= window.AdminPanneauxTab = ({ data, refreshData, showToast, setActiveTab }) => { const [isSaving, setIsSaving] = useState(false); const [previewPanneau, setPreviewPanneau] = useState(null); const [managingPanneau, setManagingPanneau] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [modalView, setModalView] = useState(null); const [editingEntity, setEditingEntity] = useState(null); const [draggedItem, setDraggedItem] = useState(null); const [moderationData, setModerationData] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); const clients = data.clients || []; const panneaux = data.panneaux || []; const demoPanneau = panneaux.find(p => p.id === 'demo-panneau') || { id: 'demo-panneau', status: 'Actif', offerType: 'demo', name: 'Panneau de démonstration', location: 'Paris', themeColor: '#059669', hasNoAds: false }; const regularPanneaux = panneaux.filter(p => p.id !== 'demo-panneau'); const unseenPanels = regularPanneaux.filter(p => !p.admin_seen); const seenPanels = regularPanneaux.filter(p => p.admin_seen); const checkPanelViolations = (p) => { const blacklist = (data.settings?.blacklist || '') .split(',') .map(s => s.trim().toLowerCase()) .filter(Boolean); const greylist = (data.settings?.greylist || '') .split(',') .map(s => s.trim().toLowerCase()) .filter(Boolean); let blackCount = 0; let greyCount = 0; const textToSearch = [p.name, p.location, p.description, p.maitreOuvrage] .filter(Boolean) .join(' ') .toLowerCase(); const detected = { black: [], grey: [] }; blacklist.forEach(word => { if(textToSearch.includes(word)) { blackCount++; detected.black.push(word); } }); greylist.forEach(word => { if(textToSearch.includes(word)) { greyCount++; detected.grey.push(word); } }); return { isViolation: blackCount > 0 || greyCount > 2, detected }; }; const handlePreviewAndMarkSeen = async (p) => { setPreviewPanneau(p); if (!p.admin_seen) { try { const payload = { id: p.id, status: p.status, offerType: p.offerType, currentRate: p.currentRate, physicalPanels: p.physicalPanels, details: { ...p, admin_seen: true } }; await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify(payload) }); refreshData(); } catch(e) { console.error("Erreur de marquage vu", e); } } }; const togglePanneauStatus = (id, currentStatus) => { const newStatus = currentStatus === 'Actif' ? 'Hors ligne' : 'Actif'; setConfirmDialog({ title: "Changement de statut", message: `Êtes-vous sûr de vouloir passer ce panneau en statut : ${newStatus} ? Si le panneau possède un abonnement Stripe, celui-ci pourrait être affecté.`, confirmText: "Confirmer", isDestructive: newStatus === 'Hors ligne' || newStatus === 'Désactivé', onConfirm: async () => { setIsSaving(true); try { await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/status', { method: 'POST', body: JSON.stringify({ id, status: newStatus }) }); refreshData(); showToast(`Le panneau est maintenant ${newStatus}.`, "success"); } catch (e) { showToast("Erreur lors du changement de statut.", "error"); } finally { setIsSaving(false); } } }); }; const handleSavePanneau = async () => { setIsSaving(true); try { const payload = { id: 'demo-panneau', status: 'Actif', offerType: 'demo', currentRate: 0, physicalPanels: 0, details: managingPanneau }; 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("Panneau de démonstration mis à jour !", "success"); setIsModalOpen(false); refreshData(); } else { showToast(d.message, 'error'); } } catch(e) { showToast("Erreur de sauvegarde", "error"); } setIsSaving(false); }; 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 de l'équipe du projet ?", 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 PanelCard = ({ c }) => { const client = clients.find(cl => cl.id === c.clientName); const { isViolation, detected } = checkPanelViolations(c); return (

{c.name}

{c.location}

Client : {client ? client.name : c.clientName}
{isViolation && ( )}

Abonnement

{c.offerType === 'rental' ? 'Loc.' : 'Achat'} ({c.currentRate}€)

Statut

{c.status}

); }; return (

Tous les panneaux

Supervision des affichages légaux de l'ensemble du parc.

Panneau de démonstration

Gérez le contenu du faux panneau affiché sur la page d'accueil publique.

{unseenPanels.length > 0 && (

À contrôler (nouveaux ou modifiés)

{unseenPanels.map(c => )}
)} {seenPanels.length > 0 && (

Déjà contrôlés

{seenPanels.map(c => )}
)} {regularPanneaux.length === 0 && (

Aucun panneau client enregistré.

)} {/* MODALES LOCALES DES PANNEAUX */} {isModalOpen && modalView === 'config_demo' && (
{ if(e.target === e.currentTarget) setIsModalOpen(false); }}>
e.stopPropagation()}>

Édition du panneau démo

Panneau public vitrine.

setIsModalOpen(false)} onSaveActive={handleSavePanneau} onEditEntity={(loc, data) => setEditingEntity({location: loc, data})} draggedItem={draggedItem} handleDragStart={handleDragStart} handleDragOver={handleDragOver} handleDrop={handleDrop} deleteEntity={deleteEntity} isSaving={isSaving} uiMode="professionnel" />
{editingEntity && ( )}
)} {previewPanneau && ( setPreviewPanneau(null)} showToast={showToast} refreshData={refreshData} /> )} {moderationData && ( setModerationData(null)} zIndex="z-[250]">

Mots détectés dans le panneau : {moderationData.panneau.name}

{moderationData.detected.black.length > 0 && (
Liste noire : {moderationData.detected.black.join(', ')}
)} {moderationData.detected.grey.length > 0 && (
Liste grise : {moderationData.detected.grey.join(', ')}
)}
)} {confirmDialog && ( setConfirmDialog(null)} /> )}
); }; // ========================================================================= // 2. ONGLET : LOGISTIQUE // ========================================================================= window.AdminLogistiqueTab = ({ data, refreshData, showToast }) => { const [isSaving, setIsSaving] = useState(false); const [shippingModal, setShippingModal] = useState(null); const [trackingData, setTrackingData] = useState({ number: '', link: '' }); const [confirmDialog, setConfirmDialog] = useState(null); const clients = data.clients || []; const panneaux = data.panneaux || []; const logiPanneaux = panneaux.filter(p => p.physicalPanels > 0 && p.id !== 'demo-panneau'); const updateShipping = async (id, status, tracking_number, tracking_link = '') => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/shipping', { method: 'POST', body: JSON.stringify({ id, shipping_status: status, tracking_number, tracking_link }) }); const d = await res.json(); if (d.status === 'success') { showToast(`Statut logistique mis à jour : ${status}`, "success"); setShippingModal(null); refreshData(); } else { showToast(d.message || "Erreur de mise à jour", "error"); } } catch(e) { showToast("Erreur réseau", "error"); } finally { setIsSaving(false); } }; const handleA1Upload = async (c, e) => { const file = e.target.files[0]; if(!file) return; setIsSaving(true); try { const id = await window.uploadFile(file, 'pdf'); const payload = { id: c.id, status: c.status, offerType: c.offerType, currentRate: c.currentRate, physicalPanels: c.physicalPanels, details: { ...c, a1PdfId: id } }; 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("Fichier A1 attaché avec succès !", "success"); refreshData(); } else { showToast(d.message, 'error'); } } catch(err) { showToast("Erreur d'upload", "error"); } setIsSaving(false); e.target.value = null; }; const handleA1Delete = (c) => { setConfirmDialog({ title: "Retirer la maquette", message: "Voulez-vous vraiment retirer ce fichier A1 de la commande ?", confirmText: "Retirer", isDestructive: true, onConfirm: async () => { setIsSaving(true); try { const payload = { id: c.id, status: c.status, offerType: c.offerType, currentRate: c.currentRate, physicalPanels: c.physicalPanels, details: { ...c, a1PdfId: '' } }; 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("Fichier A1 retiré.", "success"); refreshData(); } else { showToast(d.message, "error"); } } catch(e) { showToast("Erreur lors de la suppression", "error"); } setIsSaving(false); } }); }; return (

Logistique

Gestion des expéditions de panneaux physiques A1.

{logiPanneaux.map(c => { const client = clients.find(cl => cl.id === c.clientName); const status = c.shipping_status || 'Validation en cours'; const isValidation = status === 'Validation en cours'; const isPrinting = status === "Attente d'impression"; const isShipped = status === 'Expédié'; const isDelivered = status === 'Livré'; return (
{isDelivered ? : }

{c.name}

Client : {client?.name || 'Inconnu'}

{c.shippingAddress || client?.address || 'Adresse non renseignée'}

Quantité

{c.physicalPanels}

Statut

{status}
{isValidation && ( )} {isPrinting && ( )} {isShipped && (

En cours d'acheminement

{c.tracking_number}

)} {isDelivered && (

Réception confirmée

)}
{c.a1PdfId ? (
PDF Validé
) : (
Ou
)}
); })} {logiPanneaux.length === 0 && (

Aucune commande

Les commandes de panneaux physiques apparaîtront ici.

)}
{shippingModal && ( setShippingModal(null)} preventClose={isSaving} >

{shippingModal.shipping_status === 'Expédié' ? "Corrigez le numéro ou le lien de suivi. Le client recevra un e-mail avec les nouvelles informations." : "Saisissez les informations de suivi pour notifier le client. Le lien de suivi est obligatoire pour valider l'expédition."}

setTrackingData({...trackingData, number: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-blue-500 outline-none transition" placeholder="Ex : 6A123456789" />
setTrackingData({...trackingData, link: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-blue-500 outline-none transition" placeholder="Ex : https://www.laposte.fr/outils/suivre-vos-envois?code=..." />
)} {confirmDialog && ( setConfirmDialog(null)} /> )}
); }; // ========================================================================= // 3. ONGLET : MESSAGERIE ET SUPPORT GLOBAL // ========================================================================= window.AdminMessagesTab = ({ data, refreshData, showToast, setActiveTab }) => { const [mobileChatView, setMobileChatView] = useState(false); const [isSaving, setIsSaving] = useState(false); const [accessRequestModal, setAccessRequestModal] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); const clients = data.clients || []; const interactions = data.interactions || []; const threadsMap = {}; interactions.forEach(m => { let threadId = null; let title = ''; let targetEmail = ''; if (m.panneauId === 'CONTACT_PUBLIC') { targetEmail = (m.target === 'Admin') ? m.author : m.target; if (!targetEmail) return; threadId = `CONTACT_PUBLIC_${targetEmail}`; title = `Nous contacter : ${targetEmail}`; } else if (m.panneauId.startsWith('SUPPORT_')) { threadId = m.panneauId; const cUid = m.panneauId.split('_')[1]; const cName = clients.find(c => c.id === cUid)?.name || 'Client Inconnu'; title = `Support : ${cName}`; targetEmail = 'Client'; } if (threadId) { if (!threadsMap[threadId]) { threadsMap[threadId] = { id: threadId, title, targetEmail, messages: [], unread: 0 }; } threadsMap[threadId].messages.push(m); if (m.target === 'Admin' && !m.resolved) { threadsMap[threadId].unread++; } } }); const requestedChatId = new URLSearchParams(window.location.search).get('chat_id'); if (requestedChatId && requestedChatId.startsWith('SUPPORT_') && !threadsMap[requestedChatId]) { const cUid = requestedChatId.split('_')[1]; const clientObj = clients.find(c => c.id === cUid); if (clientObj) { threadsMap[requestedChatId] = { id: requestedChatId, title: `Support : ${clientObj.name || 'Client Inconnu'}`, targetEmail: 'Client', messages: [], unread: 0 }; } } const threads = Object.values(threadsMap).sort((a,b) => { 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 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 handleSendAdmin = async (text) => { if (!selectedThread) return; const optimisticMsg = { id: 'temp_' + Date.now(), panneauId: selectedThread.id.startsWith('CONTACT_PUBLIC') ? 'CONTACT_PUBLIC' : selectedThread.id, author: 'Admin', 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.id.startsWith('CONTACT_PUBLIC') ? 'CONTACT_PUBLIC' : selectedThread.id, detail: text, author: 'Admin', targetEmail: selectedThread.targetEmail }; try { await fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions', { method: 'POST', body: JSON.stringify(payload) }); } catch(e) { console.error("Erreur lors de l'envoi", e); } }; const sendAccessRequest = async (uid, mode) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/request_access', { method: 'POST', body: JSON.stringify({ uid, mode }) }); if ((await res.json()).status === 'success') { showToast("Demande d'accès envoyée avec succès.", "success"); setAccessRequestModal(null); refreshData(); } else { showToast("Erreur lors de la demande", "error"); } } catch (e) { showToast("Erreur réseau", "error"); } finally { setIsSaving(false); } }; const revokeAccess = async (uid) => { setConfirmDialog({ title: "Révocation d'accès", message: "Êtes-vous sûr de vouloir révoquer l'autorisation d'accès technique pour ce client ?", confirmText: "Oui, révoquer", isDestructive: true, onConfirm: async () => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/revoke_access', { method: 'POST', body: JSON.stringify({ uid }) }); if ((await res.json()).status === 'success') { showToast("Accès révoqué.", "success"); refreshData(); } else showToast("Erreur lors de la révocation", "error"); } finally { setIsSaving(false); } } }); }; const impersonateClient = async (uid) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/impersonate', { method: 'POST', body: JSON.stringify({ uid }) }); if ((await res.json()).status === 'success') window.location.href = '?'; else showToast("Erreur d'accès", "error"); } finally { setIsSaving(false); } }; const currentClient = selectedThread?.id.startsWith('SUPPORT_') ? clients.find(c => c.id === selectedThread.id.split('_')[1]) : null; const isImpersonable = currentClient && currentClient.admin_access_until && new Date(String(currentClient.admin_access_until || '').replace(' ', 'T')) > new Date(); let chatClientName = 'Client'; if (currentClient && currentClient.name) { chatClientName = currentClient.name; } const forceChatMobile = mobileChatView || !!requestedChatId || threads.length === 1; return (

Conversations

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

Aucune conversation.

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

{selectedThread.title}

{selectedThread.id.startsWith('SUPPORT_') && ( isImpersonable ? (
) : ( ) )}
) : (

Sélectionnez une conversation

)}
{/* MODALES LOCALES DE MESSAGERIE */} {accessRequestModal && ( setAccessRequestModal(null)} zIndex="z-[250]">
Comment souhaitez-vous envoyer la demande d'autorisation d'accès temporaire (24h) à ce client ?
)} {confirmDialog && ( setConfirmDialog(null)} /> )}
); }; /* EOF ========== [_www/_react/_admin_panneaux_logistique.jsx] */