/** * ========================================================================= * PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0 * Interface Administrateur (Super Admin) * Gestion globale, Logistique, Tarification et Maintenance Système * ========================================================================= */ const { useState, useEffect, useRef } = React; const { Home, Users, Building, Settings, Activity, ShieldAlert, KeyRound, Database, Save, LogOut, Package, Eye, MessageSquare, AlertTriangle, CheckCircle, Trash2, Download, Terminal, Loader, Zap, HardDrive, Shield, AlertOctagon, Archive, Power, Edit, FileText, MapPin, ExternalLink, Plus, Mail, ArrowLeft, Search, CreditCard, RefreshCw } = window; const decodeHTML = (html) => { const txt = document.createElement("textarea"); txt.innerHTML = html; return txt.value; }; window.AdminView = ({ data, refreshData, showToast }) => { const [activeTab, setActiveTab] = useState('dashboard'); const [isSaving, setIsSaving] = useState(false); const [mobileChatView, setMobileChatView] = useState(false); const [settingsData, setSettingsData] = useState({ ...data.settings, ...data.prices, hostingYr: data.prices?.hostingYr ?? 1, billing_legalText: data.settings?.billing_legalText ?? '', site_banner_active: data.settings?.site_banner_active ?? '0', site_banner_msg: data.settings?.site_banner_msg ?? '', panel_banner_active: data.settings?.panel_banner_active ?? '0', panel_banner_msg: data.settings?.panel_banner_msg ?? '', allow_new_purchases: data.settings?.allow_new_purchases ?? '1', simp_opt_description: data.settings?.simp_opt_description ?? '1', simp_opt_image: data.settings?.simp_opt_image ?? '1', simp_opt_theme: data.settings?.simp_opt_theme ?? '1', simp_opt_link: data.settings?.simp_opt_link ?? '1', simp_opt_emergency: data.settings?.simp_opt_emergency ?? '1', simp_opt_schedule: data.settings?.simp_opt_schedule ?? '1', blacklist: data.settings?.blacklist ?? 'putain, connard, salope', greylist: data.settings?.greylist ?? 'merde, chier', sec_ip_limit: data.settings?.sec_ip_limit ?? 5, sec_global_limit: data.settings?.sec_global_limit ?? 1000, sec_lock_min: data.settings?.sec_lock_min ?? 10, sec_lock_max: data.settings?.sec_lock_max ?? 60 }); const [hasTva, setHasTva] = useState(true); const [showTvaWarning, setShowTvaWarning] = useState(false); const [diagnostics, setDiagnostics] = useState(null); const [logs, setLogs] = useState(''); const [backups, setBackups] = useState([]); const [isLoadingBackups, setIsLoadingBackups] = useState(false); const [showAllBackups, setShowAllBackups] = useState(false); const [sysSummary, setSysSummary] = useState({ loaded: false, diagStatus: 'emerald', backupStatus: 'emerald', logStatus: 'emerald', php: '', ram: '', db: '', disk: '', backupsCount: 0, logDesc: '', backupDesc: '' }); const [modalView, setModalView] = useState(null); const [pwdRequest, setPwdRequest] = useState(null); const [promptDialog, setPromptDialog] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); const [accessRequestModal, setAccessRequestModal] = useState(null); // NOUVELLES MODALES DE FACTURATION const [creditModal, setCreditModal] = useState(null); const [refundModal, setRefundModal] = useState(null); const [archiveConfig, setArchiveConfig] = useState(null); const [managingPanneau, setManagingPanneau] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [editingEntity, setEditingEntity] = useState(null); const [draggedItem, setDraggedItem] = useState(null); const [previewPanneau, setPreviewPanneau] = useState(null); const [moderationData, setModerationData] = useState(null); const [shippingModal, setShippingModal] = useState(null); const [trackingData, setTrackingData] = useState({ number: '', link: '' }); const settingsLoadedRef = useRef(false); useEffect(() => { if (data.settings && !settingsLoadedRef.current) { setSettingsData({ ...data.settings, ...data.prices, hostingYr: data.prices?.hostingYr ?? 1, billing_legalText: data.settings?.billing_legalText ?? '', site_banner_active: data.settings?.site_banner_active ?? '0', site_banner_msg: data.settings?.site_banner_msg ?? '', panel_banner_active: data.settings?.panel_banner_active ?? '0', panel_banner_msg: data.settings?.panel_banner_msg ?? '', allow_new_purchases: data.settings?.allow_new_purchases ?? '1', simp_opt_description: data.settings?.simp_opt_description ?? '1', simp_opt_image: data.settings?.simp_opt_image ?? '1', simp_opt_theme: data.settings?.simp_opt_theme ?? '1', simp_opt_link: data.settings?.simp_opt_link ?? '1', simp_opt_emergency: data.settings?.simp_opt_emergency ?? '1', simp_opt_schedule: data.settings?.simp_opt_schedule ?? '1', blacklist: data.settings?.blacklist ?? 'putain, connard, salope', greylist: data.settings?.greylist ?? 'merde, chier', sec_ip_limit: data.settings?.sec_ip_limit ?? 5, sec_global_limit: data.settings?.sec_global_limit ?? 1000, sec_lock_min: data.settings?.sec_lock_min ?? 10, sec_lock_max: data.settings?.sec_lock_max ?? 60 }); setHasTva(data.settings?.billing_has_tva !== '0'); settingsLoadedRef.current = true; } }, [data]); const fetchTelemetry = async (silent = false) => { try { const cacheBuster = '&_t=' + Date.now(); const fetchOpts = silent ? { cache: 'no-store', headers: { 'x-silent': '1' } } : { cache: 'no-store' }; const [diagRes, backRes, logRes] = await Promise.all([ fetch(window.ECO_CONFIG.apiBaseUrl + 'system/diagnostics' + cacheBuster + (silent ? '&silent=1' : ''), fetchOpts).then(r => r.json()), fetch(window.ECO_CONFIG.apiBaseUrl + 'system/backups' + cacheBuster + (silent ? '&silent=1' : ''), fetchOpts).then(r => r.json()), fetch(window.ECO_CONFIG.apiBaseUrl + 'system/logs' + cacheBuster + (silent ? '&silent=1' : ''), fetchOpts).then(r => r.json()) ]); if (diagRes.status === 'success') { const dbDetail = diagRes.data.find(d => d.id === 'db')?.detail || '0 ms'; const diskDetail = diagRes.data.find(d => d.id === 'disk')?.detail || '0 GB'; const dbVal = parseFloat(dbDetail); const diskVal = parseFloat(diskDetail); let diagStatus = 'emerald'; if (dbVal > 500 || diskVal < 1) diagStatus = 'red'; else if (dbVal > 200 || diskVal < 5) diagStatus = 'amber'; const backupsData = backRes.data || []; const backupsCount = backupsData.length; let backupStatus = 'emerald'; let backupDesc = `${backupsCount} archive(s)`; if (backupsCount === 0) { backupStatus = 'red'; backupDesc = "Alerte : Aucune sauvegarde"; } else { const latestBackup = backupsData.reduce((max, b) => b.date > max.date ? b : max, backupsData[0]); const hoursSinceLast = (Date.now() - latestBackup.date) / (1000 * 60 * 60); if (hoursSinceLast > 48) { backupStatus = 'red'; backupDesc = `Critique : Retard de ${Math.floor(hoursSinceLast/24)} jours`; } else if (hoursSinceLast > 24) { backupStatus = 'amber'; backupDesc = `Attention : Retard de ${Math.floor(hoursSinceLast)}h`; } else { backupStatus = 'emerald'; backupDesc = `À jour (${backupsCount} archives)`; } } const logsContent = logRes.data?.logs || ''; let logStatus = 'emerald'; let logDesc = 'Journal vierge (Sain)'; const lowerLogs = logsContent.toLowerCase(); if (lowerLogs.includes('fatal error') || lowerLogs.includes('exception')) { logStatus = 'red'; logDesc = 'Erreurs critiques détectées !'; } else if (logsContent.length > 5) { logStatus = 'amber'; logDesc = 'Avertissements enregistrés'; } setSysSummary({ loaded: true, diagStatus, backupStatus, logStatus, php: diagRes.data.find(d => d.id === 'php')?.detail || 'OK', ram: diagRes.data.find(d => d.id === 'ram')?.detail || 'OK', db: dbDetail, disk: diskDetail, backupsCount, logDesc, backupDesc }); } } catch(e) {} }; useEffect(() => { fetchTelemetry(); let telemetryInterval; let currentDelay = 10000; let lastActivityTime = Date.now(); let isTabVisible = !document.hidden; const performTelemetrySync = async () => { if (!isTabVisible) return; await fetchTelemetry(true); const idleTime = Date.now() - lastActivityTime; if (idleTime > 120000) currentDelay = 60000; else if (idleTime > 30000) currentDelay = 30000; else currentDelay = 10000; scheduleNextPoll(currentDelay); }; const scheduleNextPoll = (delay) => { clearTimeout(telemetryInterval); if (isTabVisible) telemetryInterval = setTimeout(performTelemetrySync, delay); }; const handleVisibilityChange = () => { isTabVisible = !document.hidden; if (isTabVisible) { lastActivityTime = Date.now(); currentDelay = 10000; performTelemetrySync(); } else { clearTimeout(telemetryInterval); } }; const handleUserInteraction = () => { if (currentDelay > 10000) { lastActivityTime = Date.now(); currentDelay = 10000; scheduleNextPoll(10000); } else { lastActivityTime = Date.now(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('click', handleUserInteraction); document.addEventListener('keydown', handleUserInteraction); scheduleNextPoll(currentDelay); return () => { clearTimeout(telemetryInterval); document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('click', handleUserInteraction); document.removeEventListener('keydown', handleUserInteraction); }; }, []); const clients = data.clients || []; const panneaux = data.panneaux || []; const activePanneaux = panneaux.filter(p => p.status === 'Actif' && p.id !== 'demo-panneau'); const mrr = activePanneaux.filter(p => p.offerType === 'rental').reduce((sum, p) => sum + (p.currentRate || 0), 0); const interactions = data.interactions || []; const totalUnread = interactions.filter(m => m.target === 'Admin' && !m.resolved).length; const navItems = [ { id: 'dashboard', icon: , label: 'Vue globale' }, { id: 'clients', icon: , label: 'Base clients' }, { id: 'messages', icon: , label: 'Messagerie et support', badge: totalUnread }, { id: 'panneaux', icon: , label: 'Tous les panneaux', badge: activePanneaux.length }, { id: 'logistique', icon: , label: 'Logistique', badge: panneaux.filter(p => p.physicalPanels > 0 && p.shipping_status !== 'Livré').length }, { id: 'invoices', icon: , label: 'Facturation globale' }, { id: 'settings', icon: , label: 'Système et tarifs' }, ]; 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 deleteClient = (uid, clientName) => { setPwdRequest({ title: `Suppression du compte : ${clientName}`, desc: (ATTENTION : Saisissez le mot de passe administrateur pour détruire définitivement le compte de {clientName} et toutes ses données associées. Cette action est irréversible.), onConfirm: async (pwd) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/admin_delete', { method: 'POST', body: JSON.stringify({ uid, pwd }) }); if ((await res.json()).status === 'success') { showToast("Client supprimé avec succès."); refreshData(); } else showToast("Erreur de suppression (Mot de passe incorrect ?)", 'error'); } finally { setIsSaving(false); } } }); }; const togglePanneauStatus = (id, currentStatus) => { const newStatus = currentStatus === 'Actif' ? 'Hors ligne' : 'Actif'; setConfirmDialog({ title: "Changement de statut", message: `Passer ce panneau en statut : ${newStatus} ?`, 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(); } finally { setIsSaving(false); } } }); }; const updateShipping = async (id, status, tracking_number, tracking_link = '') => { setIsSaving(true); try { await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/shipping', { method: 'POST', body: JSON.stringify({ id, shipping_status: status, tracking_number, tracking_link }) }); showToast(`Statut logistique mis à jour : ${status}`); setShippingModal(null); refreshData(); } finally { setIsSaving(false); } }; const handleAddCredit = async (e) => { e.preventDefault(); const amount = parseFloat(e.target.amount.value); const reason = e.target.reason.value; if (amount <= 0) return showToast("Montant invalide", "error"); setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'invoices/credit', { method: 'POST', body: JSON.stringify({ client_uid: creditModal.client_uid, amount, reason }) }); const d = await res.json(); if (d.status === 'success') { showToast("Crédit ajouté avec succès !", "success"); setCreditModal(null); refreshData(); } else { showToast(d.message, 'error'); } } catch (err) { showToast("Erreur réseau", "error"); } setIsSaving(false); }; const handleRefund = async (e) => { e.preventDefault(); const amount = parseFloat(e.target.amount.value); const reason = e.target.reason.value; const mode = e.target.mode.value; if (amount <= 0 || amount > refundModal.invoice.amount) return showToast("Montant invalide", "error"); setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'invoices/refund', { method: 'POST', body: JSON.stringify({ invoice_ref: refundModal.invoice.invoiceNumber, amount, mode, reason }) }); const d = await res.json(); if (d.status === 'success') { showToast("Remboursement ou avoir traité avec succès !", "success"); setRefundModal(null); refreshData(); } else { showToast(d.message, 'error'); } } catch (err) { showToast("Erreur réseau", "error"); } 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: "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) }); if((await res.json()).status === 'success') { showToast("Fichier A1 retiré.", "success"); refreshData(); } } catch(err) {} setIsSaving(false); } }); }; const handleAutoFill = () => { setPromptDialog({ title: "Saisie automatique", message: "Saisissez le SIRET (14 chiffres) ou SIREN (9 chiffres) de l'entreprise qui gère la plateforme (Vous) :", 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 : Aucune entreprise correspondante.", "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}`; setSettingsData({ ...settingsData, billing_company: company.nom_complet, billing_address: address, billing_siret: foundSiret, billing_tva: calculatedTva }); setHasTva(true); showToast("Informations récupérées avec succès !", "success"); } else showToast("Aucune entreprise trouvée.", "error"); } catch (err) { showToast("Le service gouvernemental est momentanément indisponible.", "error"); } setIsSaving(false); } }); }; const handleToggleTva = () => { if (hasTva) { setShowTvaWarning(true); } else { setHasTva(true); } }; const confirmTvaExemption = () => { setHasTva(false); setSettingsData({ ...settingsData, billing_tva: '' }); setShowTvaWarning(false); }; const handleSaveSettings = () => { setPwdRequest({ title: "Modification globale", desc: "Saisissez le mot de passe administrateur pour confirmer la modification des paramètres de la plateforme.", onConfirm: async (pwd) => { setIsSaving(true); try { const payload = { ...settingsData, pwd, billing_has_tva: hasTva ? '1' : '0', price_rentalMo: settingsData.rentalMo, price_purchase: settingsData.purchase, price_boardFirst: settingsData.boardFirst, price_boardAdd: settingsData.boardAdd, price_noAds: settingsData.noAds, price_hostingYr: settingsData.hostingYr, billing_legalText: settingsData.billing_legalText, sec_ip_limit: settingsData.sec_ip_limit, sec_global_limit: settingsData.sec_global_limit, sec_lock_min: settingsData.sec_lock_min, sec_lock_max: settingsData.sec_lock_max }; const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'settings/update', { method: 'POST', body: JSON.stringify(payload) }); if ((await res.json()).status === 'success') { showToast("Paramètres globaux mis à jour."); refreshData(); } else showToast("Mot de passe invalide", '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 !"); 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 ?", 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 fetchDiagnostics = async () => { setModalView('diagnostics'); setDiagnostics(null); const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/diagnostics&_t=' + Date.now(), { cache: 'no-store' }); const d = await res.json(); if(d.status === 'success') setDiagnostics(d.data); }; const fetchLogs = async () => { setModalView('logs'); setLogs('Chargement...'); const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/logs&_t=' + Date.now(), { cache: 'no-store' }); const d = await res.json(); setLogs(d.data?.logs || 'Aucun log trouvé.'); }; const clearLogs = async () => { await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/logs/clear', { method: 'POST' }); fetchLogs(); fetchTelemetry(); }; const loadBackups = async () => { setModalView('backups'); setShowAllBackups(false); setIsLoadingBackups(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/backups&_t=' + Date.now(), { cache: 'no-store' }); const d = await res.json(); if(d.status === 'success') setBackups(d.data); } catch(e) { showToast("Erreur lors du chargement des sauvegardes", "error"); } finally { setIsLoadingBackups(false); } }; const createBackup = () => { setPromptDialog({ title: "Nouvelle sauvegarde", message: "Saisissez un marqueur ou label personnalisé pour identifier cette sauvegarde manuelle :", placeholder: "Ex : Avant la mise à jour...", confirmText: "Sauvegarder", onConfirm: async (label) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/backups/create', { method: 'POST', body: JSON.stringify({ label }) }); if ((await res.json()).status === 'success') { showToast("Sauvegarde créée avec succès.", "success"); loadBackups(); fetchTelemetry(); } else showToast("Erreur de sauvegarde", "error"); } catch(e) { showToast("Erreur réseau", "error"); } finally { setIsSaving(false); } } }); }; const restoreBackup = (filename) => { setPwdRequest({ title: "Restauration système", desc: "ATTENTION CRITIQUE : La restauration écrasera la base de données actuelle. Saisissez le mot de passe administrateur :", onConfirm: async (pwd) => { setIsSaving(true); try { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/backups/restore', { method: 'POST', body: JSON.stringify({ filename, pwd }) }); if ((await res.json()).status === 'success') { alert("Restauration réussie. L'application va recharger."); window.location.reload(); } else showToast("Mot de passe incorrect ou erreur.", "error"); } finally { setIsSaving(false); } } }); }; const deleteBackup = (filename) => { setPwdRequest({ title: "Suppression d'archive", desc: "Saisissez le mot de passe administrateur pour supprimer définitivement cette sauvegarde :", onConfirm: async (pwd) => { const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/backups/delete', { method: 'POST', body: JSON.stringify({ filename, pwd }) }); if ((await res.json()).status === 'success') { loadBackups(); fetchTelemetry(); } else { showToast("Mot de passe incorrect", "error"); } } }); }; const getColorClass = (status) => { if (!sysSummary.loaded) return 'text-slate-500 border-slate-200 bg-slate-50'; if (status === 'red') return 'text-red-700 border-red-200 bg-red-50 hover:border-red-400'; if (status === 'amber') return 'text-amber-700 border-amber-200 bg-amber-50 hover:border-amber-400'; return 'text-emerald-700 border-emerald-200 bg-emerald-50 hover:border-emerald-400'; }; 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) {} } }; const renderDashboard = () => { const recentMessages = [...interactions].filter(m => m.target === 'Admin').reverse().slice(0, 5); return (

Supervision

Vue d'ensemble de la plateforme SaaS.

} color="text-emerald-600" bg="bg-emerald-100" /> } color="text-blue-600" bg="bg-blue-100" onClick={() => setActiveTab('clients')} /> } color="text-indigo-600" bg="bg-indigo-100" onClick={() => setActiveTab('panneaux')} /> } color="text-amber-600" bg="bg-amber-100" />

Derniers messages

{recentMessages.map((m, i) => { let displayAuthor = m.author; if (m.panneauId === 'CONTACT_PUBLIC') { if (m.author === 'Admin') displayAuthor = 'Service client'; else if (m.author === 'Système' || m.author === 'Systeme') displayAuthor = 'Système'; else displayAuthor = `Visiteur ${m.author}`; } else { if (m.author === 'Admin') displayAuthor = 'Support'; else if (m.author === 'Client') { if (m.panneauId.startsWith('SUPPORT_')) { const cUid = m.panneauId.split('_')[1]; const c = clients.find(cl => cl.id === cUid); if (c && c.name) displayAuthor = c.name; } else displayAuthor = 'Client'; } else if (m.author === 'Système' || m.author === 'Systeme') displayAuthor = 'Système'; } return (
{ let tId = m.panneauId; if (m.panneauId === 'CONTACT_PUBLIC') { tId = `CONTACT_PUBLIC_${m.author}`; } else if (m.panneauId.startsWith('SUPPORT_')) { tId = m.panneauId; } else if (m.author !== 'Client' && m.author !== 'Admin') { tId = `${m.panneauId}_${m.author}`; } else if (m.target !== 'Client' && m.target !== 'Admin' && m.target) { tId = `${m.panneauId}_${m.target}`; } const u = new URL(window.location); if (tId) u.searchParams.set('chat_id', tId); window.history.replaceState({}, '', u); setMobileChatView(true); setActiveTab('messages'); }}> {/* CORRECTION BUG SAFARI */}

{displayAuthor}

{new Date(String(m.created_at || '').replace(' ', 'T')).toLocaleDateString('fr-FR')}

{m.detail.replace(/\[ATTACHMENT:[^\]]+\]/g, '[Pièce jointe]').replace(/<[^>]*>?/gm, '')}

{m.panneauId === 'CONTACT_PUBLIC' ? 'ORIGINE : Nous contacter' : 'ORIGINE : Espace client (Support)'}

); })} {recentMessages.length === 0 &&

Aucune interaction récente.

}

Serveur et maintenance

); }; const renderClients = () => (

Base clients

Gestion des comptes et prise de contrôle.

{clients.map(c => { // CORRECTION : Contournement bug Safari Date const isImpersonable = c.admin_access_until && new Date(String(c.admin_access_until || '').replace(' ', 'T')) > new Date(); const isSuspended = c.paymentStatus === 'suspended'; return (

{c.name}

{c.email}

ID : {c.id.substring(0,8)}

{isSuspended ? 'Suspendu' : 'Actif'} {c.discount > 0 && Remise {c.discount}%}
{isImpersonable ? (
) : ( )} {/* NOUVEAU BOUTON : Créditer le client */}
); })} {clients.length === 0 && (

Aucun client enregistré.

)}
); const renderPanneaux = () => { 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 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.

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

)}
); }; const renderLogistique = () => { const logiPanneaux = panneaux.filter(p => p.physicalPanels > 0 && p.id !== 'demo-panneau'); 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 (
{/* Identité */}
{isDelivered ? : }

{c.name}

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

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

{/* Quantité */}

Quantité

{c.physicalPanels}
{/* Statut et Boutons d'Action Logistique */}

Statut

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

En cours d'acheminement

{c.tracking_number}

)} {isDelivered && (

Réception confirmée

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

Aucune commande

Les commandes de panneaux physiques apparaîtront ici.

)}
); }; const renderInvoices = () => (

Facturation globale

Historique des paiements de tous les clients.

Export CSV
{data.invoices?.map((inv, i) => { const client = clients.find(c => c.id === inv.clientName); return ( ); })}
DatePanneauDétailMontantPDF
{new Date(String(inv.created_at || '').replace(' ', 'T')).toLocaleDateString('fr-FR')} {inv.panneauName} {inv.type} {inv.amount} € {/* NOUVEAU BOUTON : Rembourser / Avoir */} {!inv.type.includes('Avoir') && ( )}
{(!data.invoices || data.invoices.length === 0) && (

Aucune facture émise.

)}
); const renderMessages = () => { 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 }; fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions', { method: 'POST', body: JSON.stringify(payload) }); }; 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

)}
); }; const renderSettings = () => (

Paramètres système

Tarification globale et mentions légales.

Cyberdéfense et pare-feu (Panic Mode)

Paramétrez les seuils de déclenchement du confinement automatique en cas d'attaque réseau.

Nombre d'erreurs de mot de passe avant bannissement de l'IP (15 min).

setSettingsData({...settingsData, sec_ip_limit: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-2 text-sm focus:border-red-500 outline-none transition" />

Nombre d'IPs malveillantes simultanées avant confinement du site.

setSettingsData({...settingsData, sec_global_limit: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-2 text-sm focus:border-red-500 outline-none transition" />

En minutes (Valeur aléatoire entre le min et le max).

setSettingsData({...settingsData, sec_lock_min: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-2 text-sm focus:border-red-500 outline-none transition" />

En minutes.

setSettingsData({...settingsData, sec_lock_max: e.target.value})} className="w-full border-2 border-slate-200 rounded-xl p-2 text-sm focus:border-red-500 outline-none transition" />

Paramètres des ventes

setSettingsData({...settingsData, rentalMo: v})} /> setSettingsData({...settingsData, purchase: v})} /> setSettingsData({...settingsData, hostingYr: v})} /> setSettingsData({...settingsData, boardFirst: v})} /> setSettingsData({...settingsData, boardAdd: v})} /> setSettingsData({...settingsData, noAds: v})} />

Mentions légales (CGV et RGPD)

Émetteur (facturation)

setSettingsData({...settingsData, billing_company: e.target.value})} className="w-full border border-slate-200 rounded-lg p-2 text-sm outline-none focus:border-slate-400 bg-white" />
setSettingsData({...settingsData, billing_siret: e.target.value})} className="w-full border border-slate-200 rounded-lg p-2 text-sm outline-none focus:border-slate-400 bg-white" />
setSettingsData({...settingsData, billing_address: e.target.value})} className="w-full border border-slate-200 rounded-lg p-2 text-sm outline-none focus:border-slate-400 bg-white" />
{hasTva && (
setSettingsData({...settingsData, billing_tva: e.target.value})} placeholder="FR..." className="w-full border border-slate-200 rounded-lg p-2 text-sm outline-none focus:border-slate-400 uppercase bg-white" />
)}

Bandeaux d'information

Affichez des messages importantes aux utilisateurs de la plateforme.

Visible par les clients et visiteurs (sauf espace riverain).

Visible uniquement par les riverains qui scannent un panneau.

Modération et mots-clés

Ces listes permettent de détecter les panneaux ou messages ne respectant pas les règles de bienséance. (Mots séparés par des virgules)

Options du mode simplifié

Sélectionnez les fonctionnalités accessibles aux clients utilisant l'interface "Simplifiée".

{[ { key: 'simp_opt_description', label: 'Description des travaux' }, { key: 'simp_opt_image', label: 'Image du panneau' }, { key: 'simp_opt_theme', label: 'Couleur du thème' }, { key: 'simp_opt_link', label: 'Lien promoteur' }, { key: 'simp_opt_emergency', label: 'N° d\'urgence' }, { key: 'simp_opt_schedule', label: 'Horaires des nuisances' } ].map(opt => ( ))}
); // --- RENDU GLOBAL --- return ( { await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/logout'); window.location.href='?'; }}> {activeTab === 'dashboard' && renderDashboard()} {activeTab === 'clients' && renderClients()} {activeTab === 'panneaux' && renderPanneaux()} {activeTab === 'logistique' && renderLogistique()} {activeTab === 'invoices' && renderInvoices()} {activeTab === 'messages' && renderMessages()} {activeTab === 'settings' && renderSettings()} {/* Modale de décision pour les panneaux sous alerte de modération */} {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(', ')}
)}
)} {/* Modale de choix de la demande d'accès */} {accessRequestModal && ( setAccessRequestModal(null)} zIndex="z-[250]">
Comment souhaitez-vous envoyer la demande d'autorisation d'accès temporaire (24h) à ce client ?
)} {/* Modale d'expédition logistique */} {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=..." />
)} {/* Modale Créditer Compte Client */} {creditModal && ( setCreditModal(null)} preventClose={isSaving}>
Ajout d'un crédit (bon d'achat)
Ce montant sera ajouté au solde du client et déduit automatiquement de ses prochains achats ou abonnements.
)} {/* Modale Émettre Avoir / Rembourser Facture */} {refundModal && ( setRefundModal(null)} preventClose={isSaving}>
Facture initiale : {refundModal.invoice.amount} €
Générez une facture d'avoir (partielle ou totale) pour annuler comptablement cette transaction.
)} {showTvaWarning && ( setShowTvaWarning(false)}>

Exonération de TVA

En désactivant ce champ, vous indiquez que votre propre société (la plateforme eco-panneau) n'est pas assujettie à la TVA (ex : auto-entrepreneur).

Conséquence immédiate :

  • Toutes vos futures factures seront émises sans TVA (Prix TTC = Prix HT).
  • La mention légale "TVA non applicable, art. 293 B du CGI" figurera sur les factures clients.
  • Les montants envoyés à Stripe ne seront plus majorés de 20%.
)} {pwdRequest && ( setPwdRequest(null)} zIndex="z-[250]">
{pwdRequest.desc}
{ e.preventDefault(); const pwd = e.target.pwd.value; if(pwd) { pwdRequest.onConfirm(pwd); setPwdRequest(null); } }}>
)} {promptDialog && setPromptDialog(null)} />} {confirmDialog && setConfirmDialog(null)} />} {/* Autres Modales Système... */} {modalView === 'diagnostics' && ( setModalView(null)}> {!diagnostics ?

Analyse complète en cours...

: (
{diagnostics.map(d => (

{d.label}

{d.status}

{d.detail}
))}
)}
)} {modalView === 'logs' && ( setModalView(null)}>
{logs}
)} {modalView === 'backups' && ( setModalView(null)} preventClose={isLoadingBackups}>
{isLoadingBackups ? (

Chargement en cours...

) : ( <> {backups.slice(0, showAllBackups ? backups.length : 5).map(b => (
{b.label === 'Automatique' ? 'CRON' : 'MANUEL'}

{decodeHTML(b.label)}

{new Date(b.date).toLocaleString('fr-FR')} • {b.size}

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

Aucune sauvegarde trouvée.

} {!showAllBackups && backups.length > 5 && ( )} )}
)} {isModalOpen && modalView === 'config_demo' && (
{ if(e.target === e.currentTarget) document.getElementById('editor-cancel-btn')?.click(); }}>
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 && }
)} {/* Modale d'Aperçu de panneau */} {previewPanneau && ( setPreviewPanneau(null)} showToast={showToast} refreshData={refreshData} /> )} {/* MODALE GLOBALE DE GENERATION D'ARCHIVES */} {archiveConfig && ( setArchiveConfig(null)} showToast={showToast} refreshData={refreshData} /> )}
); }; /* EOF ============================ [_www/_react/_administrateur.jsx] */