/**
* =========================================================================
* PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0
* Interface Client B2B (Tableau de bord, Facturation, Gestion des panneaux)
* =========================================================================
*/
const { useState, useEffect, useMemo, useRef } = React;
const {
Home, Building, MessageSquare, FileText, UserCircle, Plus, Search,
CheckCircle, AlertTriangle, Shield, Copy, Archive, Trash2, Edit, Eye,
CreditCard, Download, Loader, LogOut, ArrowLeft, KeyRound, ShieldAlert,
Lock, ShieldCheck, Mail, Users, Bell, Power, Settings, FileCheck, Image, X, Zap, Package, Share2, RefreshCw, MapPin, ExternalLink, ArrowUp
} = window;
// =========================================================================
// 1. COMPOSANT STRIPE : FORMULAIRE DE PAIEMENT
// =========================================================================
const StripePaymentForm = ({ clientSecret, onSuccess, onCancel, amountCents }) => {
const stripePromise = useMemo(() => window.Stripe(window.ECO_CONFIG.stripePubKey), []);
const options = useMemo(() => ({ clientSecret, appearance: { theme: 'stripe', variables: { colorPrimary: '#10b981', borderRadius: '12px' } } }), [clientSecret]);
const CheckoutForm = () => {
const stripeInstance = window.ReactStripe.useStripe();
const elements = window.ReactStripe.useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!stripeInstance || !elements) return;
setIsProcessing(true); setError(null);
const { error: submitError } = await elements.submit();
if (submitError) { setError(submitError.message); setIsProcessing(false); return; }
const { error: confirmError, paymentIntent } = await stripeInstance.confirmPayment({
elements, confirmParams: { return_url: window.location.href }, redirect: "if_required"
});
if (confirmError) { setError(confirmError.message); setIsProcessing(false); }
else if (paymentIntent && paymentIntent.status === "succeeded") { onSuccess(paymentIntent.id); }
else { setError("Statut de paiement inattendu."); setIsProcessing(false); }
};
return (
);
};
return (
);
};
// =========================================================================
// 2. VUE PRINCIPALE DU CLIENT B2B
// =========================================================================
window.ClientView = ({ data, refreshData, showToast }) => {
const [activeTab, setActiveTab] = useState('dashboard');
const [uiMode, setUiMode] = useState('simplifie');
const [pushEnabled, setPushEnabled] = useState(localStorage.getItem('eco_push_enabled') === 'true' && window.Notification?.permission === 'granted');
const [mobileChatView, setMobileChatView] = useState(false);
// Modales partagées natives-like
const [confirmDialog, setConfirmDialog] = useState(null);
const [promptDialog, setPromptDialog] = useState(null);
const [legalModal, setLegalModal] = useState(null); // 'cgv' | 'rgpd'
const [archiveConfig, setArchiveConfig] = useState(null);
const [managingPanneau, setManagingPanneau] = useState(null);
const [editingEntity, setEditingEntity] = useState(null);
const [draggedItem, setDraggedItem] = useState(null);
const [isSaving, setIsSaving] = useState(false);
// Renonciation légale au droit de rétractation
const [waiverAccepted, setWaiverAccepted] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalStep, setModalStep] = useState('config_full');
const [validationErrors, setValidationErrors] = useState([]);
const [paymentData, setPaymentData] = useState(null);
const [previewPanneau, setPreviewPanneau] = useState(null);
// Coffre-fort légal
const [vaultDragging, setVaultDragging] = useState(false);
const [viewingDoc, setViewingDoc] = useState(null);
// Gestionnaire de liens délégués
const [newLink, setNewLink] = useState({ name: '', expDays: 7, lockedFields: [] });
// Modale de confirmation de livraison
const [deliveryConfirmModal, setDeliveryConfirmModal] = useState(null);
const [profileData, setProfileData] = useState({ type_client: 'entreprise', employees_gt_5: false, name: '', address: '', siret: '', tva: '', monthlyReport: 0, two_factor_method: 'none' });
const [hasTva, setHasTva] = useState(true);
const [showTvaWarning, setShowTvaWarning] = useState(false);
const [passwordData, setPasswordData] = useState({ oldPassword: '', newPassword: '' });
const [teamEmail, setTeamEmail] = useState('');
const [totpSetupData, setTotpSetupData] = useState(null);
const [meRes, setMeRes] = useState(null);
useEffect(() => {
fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/me').then(r=>r.json()).then(d=>setMeRes(d.data));
}, []);
const myClientData = data.clients?.find(c => c.id === meRes?.user_id) || data.clients?.[0] || {};
const isSuspended = myClientData.paymentStatus === 'suspended';
const isCollaborator = !data.team;
const isImpersonating = meRes?.is_impersonating;
// CORRECTION : Protection de la date avec String()
const isSupportLocked = myClientData.admin_access_until && new Date(String(myClientData.admin_access_until || '').replace(' ', 'T')) > new Date();
const isLockedForClient = isSupportLocked && !isImpersonating;
const purchasesAllowed = data.settings?.allow_new_purchases !== '0';
const profileLoadedRef = useRef(false);
useEffect(() => {
if (myClientData.id && !profileLoadedRef.current) {
if (myClientData.uiMode) setUiMode(myClientData.uiMode);
const isTvaExempt = myClientData.tva === 'NON_ASSUJETTI';
setHasTva(!isTvaExempt);
setProfileData({
type_client: myClientData.type_client || 'entreprise',
employees_gt_5: myClientData.employees_gt_5 == 1 || myClientData.employees_gt_5 === true,
name: myClientData.name || '', address: myClientData.address || '',
siret: myClientData.siret || '', tva: isTvaExempt ? '' : (myClientData.tva || ''),
monthlyReport: myClientData.monthlyReport || 0,
two_factor_method: myClientData.two_factor_method || 'none'
});
profileLoadedRef.current = true;
}
}, [myClientData]);
// Écouteur de copier/coller pour le coffre-fort
useEffect(() => {
if (modalStep !== 'legal_vault' || !isModalOpen) return;
const handlePaste = (e) => {
if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) {
handleVaultUpload(e.clipboardData.files);
}
};
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, [modalStep, isModalOpen, managingPanneau]);
useEffect(() => {
const url = new URL(window.location);
const confirmDelivery = url.searchParams.get('confirm_delivery');
if (confirmDelivery) {
setActiveTab('livraisons');
setDeliveryConfirmModal(confirmDelivery);
url.searchParams.delete('confirm_delivery');
window.history.replaceState({}, '', url);
}
}, []);
const handleConfirmDelivery = () => {
if (!deliveryConfirmModal) return;
setIsSaving(true);
fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/shipping', {
method: 'POST',
body: JSON.stringify({ id: deliveryConfirmModal, shipping_status: 'Livré' })
}).then(res => res.json()).then(d => {
if (d.status === 'success') {
showToast("Réception confirmée, merci !");
refreshData();
setDeliveryConfirmModal(null);
} else {
showToast(d.message || "Une erreur est survenue", "error");
}
}).finally(() => setIsSaving(false));
};
const activePanneaux = data.panneaux?.filter(c => c.status === 'Actif') || [];
const draftPanneaux = data.panneaux?.filter(c => c.status === 'Brouillon') || [];
const interactions = data.interactions || [];
const totalUnread = interactions.filter(m => !m.resolved && m.author !== 'Client').length;
const activeDeliveries = data.panneaux?.filter(c => c.physicalPanels > 0 && c.shipping_status !== 'Livré') || [];
// --- CALCUL DES TARIFS GLOBAUX UTILES A LA PAGE ---
const pRental = data.prices?.rentalMo ?? 180;
const pPurchase = data.prices?.purchase ?? 850;
const pHosting = data.prices?.hostingYr ?? 1;
const pBoardFirst = data.prices?.boardFirst ?? 250;
const pBoardAdd = data.prices?.boardAdd ?? 150;
const pNoAds = data.prices?.noAds ?? 150;
const originalPanneau = data.panneaux?.find(p => p.id === managingPanneau?.id);
const isActif = originalPanneau?.status === 'Actif';
const originalOfferType = originalPanneau?.offerType || 'rental';
const originalPanels = originalPanneau?.physicalPanels || 0;
const originalHasNoAds = originalPanneau?.hasNoAds || false;
const currentOfferType = managingPanneau?.offerType || 'rental';
const currentPanels = managingPanneau?.physicalPanels || 0;
const currentHasNoAds = managingPanneau?.hasNoAds || false;
let addedPanelsCost = 0;
if (currentPanels > originalPanels) {
const costNew = currentPanels > 0 ? pBoardFirst + (currentPanels - 1) * pBoardAdd : 0;
const costOld = originalPanels > 0 ? pBoardFirst + (originalPanels - 1) * pBoardAdd : 0;
addedPanelsCost = Math.max(0, costNew - costOld);
}
let upfrontHT = 0;
if (!isActif) {
upfrontHT = (currentPanels > 0 ? pBoardFirst + (currentPanels - 1) * pBoardAdd : 0) + (currentHasNoAds ? pNoAds : 0) + (currentOfferType === 'rental' ? pRental : pPurchase + pHosting);
} else {
if (originalOfferType === 'rental' && currentOfferType === 'purchase') upfrontHT += pPurchase + pHosting;
if (currentHasNoAds && !originalHasNoAds) upfrontHT += pNoAds;
upfrontHT += addedPanelsCost;
}
const discountPct = myClientData.discount || 0;
const priceMultiplier = Math.max(0, (100 - discountPct) / 100);
const platformHasTva = data.settings?.billing_has_tva !== '0';
const tvaMult = platformHasTva ? 1.20 : 1.00;
const finalUpfrontTTC = upfrontHT * priceMultiplier * tvaMult;
const finalMonthlyTTC = pRental * priceMultiplier * tvaMult;
const handleLogout = async () => {
await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/logout');
window.location.href = '?';
};
const handleUnimpersonate = async () => {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/unimpersonate', { method: 'POST' });
const d = await res.json();
if (d.status === 'success') { window.location.href = '?'; } else { showToast(d.message, 'error'); }
};
const handleRevokeAccess = async () => {
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/revoke_access', { method: 'POST' });
if ((await res.json()).status === 'success') {
showToast("Accès révoqué avec succès !", "success");
if (isImpersonating) window.location.href = '?';
else {
refreshData();
setActiveTab('dashboard');
}
}
} finally { setIsSaving(false); }
};
let finalCustomLogout = null;
if (isLockedForClient) {
finalCustomLogout = { label: "Stopper l'assistance", icon: , action: handleRevokeAccess, className: "bg-red-600 hover:bg-red-700 text-white" };
} else if (isImpersonating) {
finalCustomLogout = { label: "Clôturer l'intervention", icon: , action: handleRevokeAccess, className: "bg-emerald-600 hover:bg-emerald-700 text-white" };
}
let finalNavItems = [
{ id: 'dashboard', icon: , label: 'Tableau de bord', badge: 0 },
{ id: 'panels', icon: , label: 'Mes panneaux', badge: 0 },
{ id: 'livraisons', icon: , label: 'Livraisons', badge: activeDeliveries.length },
{ id: 'messages', icon: , label: 'Messagerie', badge: totalUnread },
...(isCollaborator ? [] : [{ id: 'billing', icon: , label: 'Factures', badge: 0 }]),
{ id: 'account', icon: , label: 'Mon compte', badge: 0 },
];
if (isLockedForClient) {
finalNavItems = [ { id: 'messages', icon: , label: 'Messagerie', badge: totalUnread } ];
if (activeTab !== 'messages') setActiveTab('messages');
}
const saveEditedEntity = () => {
let newInter = [...(managingPanneau.intervenants || [])];
let newLots = JSON.parse(JSON.stringify(managingPanneau.lots || []));
const loc = editingEntity.location;
if (loc.type === 'intervenant') {
if (loc.index !== undefined) newInter[loc.index] = editingEntity.data;
else newInter.push({...editingEntity.data, id: crypto.randomUUID()});
} else if (loc.type === 'lot') {
if (loc.index !== undefined) newLots[loc.index] = {...newLots[loc.index], ...editingEntity.data};
else newLots.push({...editingEntity.data, id: crypto.randomUUID(), entreprises: []});
} else if (loc.type === 'entreprise') {
if (loc.index !== undefined) newLots[loc.lotIndex].entreprises[loc.index] = editingEntity.data;
else newLots[loc.lotIndex].entreprises.push({...editingEntity.data, id: crypto.randomUUID()});
}
setManagingPanneau({ ...managingPanneau, intervenants: newInter, lots: newLots });
setEditingEntity(null);
};
const deleteEntity = (loc) => {
setConfirmDialog({
title: "Supprimer l'élément", message: "Êtes-vous sûr de vouloir supprimer cet élément ?", confirmText: "Supprimer", isDestructive: true,
onConfirm: () => {
let newInter = [...(managingPanneau.intervenants || [])];
let newLots = JSON.parse(JSON.stringify(managingPanneau.lots || []));
if (loc.type === 'intervenant') newInter.splice(loc.index, 1);
else if (loc.type === 'lot') newLots.splice(loc.index, 1);
else newLots[loc.lotIndex].entreprises.splice(loc.index, 1);
setManagingPanneau({ ...managingPanneau, intervenants: newInter, lots: newLots });
}
});
};
const handleDragStart = (e, location) => { setDraggedItem(location); e.dataTransfer.effectAllowed = 'move'; };
const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
const handleDrop = (e, dropLocation) => {
e.preventDefault();
if (!draggedItem || draggedItem.type !== dropLocation.type) return;
if (draggedItem.type === 'entreprise' && draggedItem.lotIndex !== dropLocation.lotIndex) return;
let newInter = [...(managingPanneau.intervenants || [])];
let newLots = JSON.parse(JSON.stringify(managingPanneau.lots || []));
if (draggedItem.type === 'intervenant') { const item = newInter.splice(draggedItem.index, 1)[0]; newInter.splice(dropLocation.index, 0, item); }
else if (draggedItem.type === 'lot') { const item = newLots.splice(draggedItem.index, 1)[0]; newLots.splice(dropLocation.index, 0, item); }
else if (draggedItem.type === 'entreprise') { const arr = newLots[dropLocation.lotIndex].entreprises; const item = arr.splice(draggedItem.index, 1)[0]; arr.splice(dropLocation.index, 0, item); }
setManagingPanneau({ ...managingPanneau, intervenants: newInter, lots: newLots });
setDraggedItem(null);
};
const validatePanel = () => {
const errors = [];
if (!managingPanneau.name?.trim()) errors.push("Le nom du chantier est manquant.");
if (!managingPanneau.location?.trim()) errors.push("L'adresse du panneau est manquante.");
if (!managingPanneau.pdfId) errors.push("L'arrêté légal (PDF) n'a pas été uploadé.");
if (!isCollaborator) {
if (!profileData.name?.trim()) errors.push("Facturation : raison sociale ou nom manquant (onglet Mon compte).");
if (!profileData.address?.trim()) errors.push("Facturation : adresse postale manquante (onglet Mon compte).");
if (profileData.type_client === 'entreprise' && !profileData.siret?.trim()) errors.push("Facturation : SIRET de l'entreprise manquant (onglet Mon compte).");
}
return errors;
};
const handleSavePanneau = async (forceDraft = false) => {
if (!forceDraft && managingPanneau.status === 'Actif') {
const errs = validatePanel();
if (errs.length > 0) { setValidationErrors(errs); return; }
}
setIsSaving(true);
try {
const payload = {
id: managingPanneau.id, status: managingPanneau.status, offerType: managingPanneau.offerType, currentRate: managingPanneau.currentRate, physicalPanels: managingPanneau.physicalPanels,
details: { ...managingPanneau, name: managingPanneau.name?.trim() || 'Brouillon sans nom', location: managingPanneau.location?.trim() || '' }
};
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify(payload) });
const d = await res.json();
if(d.status === 'success') { showToast(forceDraft ? "Brouillon sauvegardé !" : "Panneau mis à jour !"); setIsModalOpen(false); refreshData(); }
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur de sauvegarde", "error"); }
setIsSaving(false);
};
const handleActivationRequest = async () => {
if(isSuspended) return showToast("Votre compte est suspendu. Veuillez régulariser vos factures.", "error");
// Si le client n'a pas assez de solde pour payer, la CB est requise
const remainingToPay = Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0));
if(remainingToPay > 0 && (!waiverAccepted || !purchasesAllowed)) {
if (!purchasesAllowed) return showToast("Les nouvelles commandes sont suspendues.", "error");
return showToast("Veuillez accepter les conditions de rétractation.", "error");
}
setIsSaving(true);
try {
const payload = { panneau_id: managingPanneau.id, offer_type: managingPanneau.offerType, physical_panels: managingPanneau.physicalPanels, hasNoAds: managingPanneau.hasNoAds };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'stripe/intent', { method: 'POST', body: JSON.stringify(payload) });
const d = await res.json();
if(d.status === 'success') {
if(d.data.free_activation_required) {
const freeRes = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/activate_free', { method: 'POST', body: JSON.stringify({ ...payload, id: managingPanneau.id, offerType: managingPanneau.offerType, physicalPanels: managingPanneau.physicalPanels, details: managingPanneau, name: managingPanneau.name }) });
if((await freeRes.json()).status === 'success') { showToast("Mise à jour et activation réussie !"); setIsModalOpen(false); refreshData(); }
else showToast("Erreur d'activation", "error");
} else {
setPaymentData({ secret: d.data.clientSecret, subId: d.data.subscription_id, amountCents: d.data.amountCents });
setModalStep('payment');
}
} else showToast(d.message, 'error');
} catch(e) { showToast("Erreur de communication", "error"); }
setIsSaving(false);
};
const handlePaymentSuccess = async (pi_id) => {
setIsSaving(true);
try {
const payload = { payment_intent_id: pi_id, subscription_id: paymentData?.subId, id: managingPanneau.id, offerType: managingPanneau.offerType, currentRate: managingPanneau.currentRate, physicalPanels: managingPanneau.physicalPanels, details: managingPanneau, name: managingPanneau.name };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/activate_paid', { method: 'POST', body: JSON.stringify(payload) });
const d = await res.json();
if(d.status === 'success') { showToast("Paiement validé !"); setIsModalOpen(false); refreshData(); }
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur", "error"); }
setIsSaving(false);
};
const handleCreateDelegateLink = async () => {
if (!newLink.name.trim()) return showToast("Veuillez donner un nom au lien.", "error");
setIsSaving(true);
try {
const linkId = crypto.randomUUID();
const expTime = Math.floor(Date.now() / 1000) + (newLink.expDays * 86400);
const linkObj = { id: linkId, name: newLink.name.trim(), exp: expTime, active: true, lockedFields: newLink.lockedFields };
const updatedPanneau = { ...managingPanneau, delegate_links: [...(managingPanneau.delegate_links || []), linkObj] };
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
setManagingPanneau(updatedPanneau);
setNewLink({ name: '', expDays: 7, lockedFields: [] });
refreshData();
showToast("Nouveau lien de saisie créé.", "success");
} catch(e) { showToast("Erreur lors de la création", "error"); }
setIsSaving(false);
};
const handleDeleteTeamMember = (uid) => {
setConfirmDialog({
title: "Supprimer le collaborateur", message: "Voulez-vous vraiment supprimer ce collaborateur ?", isDestructive: true, confirmText: "Supprimer",
onConfirm: async () => {
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/team/delete', { method: 'POST', body: JSON.stringify({ uid }) });
if ((await res.json()).status === 'success') { showToast("Collaborateur supprimé."); refreshData(); }
} finally { setIsSaving(false); }
}
});
};
const handleAssignPanel = async (panneauId, assignedUid) => {
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/assign', { method: 'POST', body: JSON.stringify({ panneau_id: panneauId, assigned_uid: assignedUid }) });
if ((await res.json()).status === 'success') { showToast("Assignation mise à jour."); refreshData(); }
} finally { setIsSaving(false); }
};
const handleAutoFill = () => {
setPromptDialog({
title: "Saisie automatique", message: "Saisissez votre numéro SIRET (14 chiffres) ou SIREN (9 chiffres) :", placeholder: "N° SIREN ou SIRET", confirmText: "Rechercher",
onConfirm: async (num) => {
const cleanNum = num.replace(/\D/g, '');
if (cleanNum.length !== 9 && cleanNum.length !== 14) return showToast("Le numéro doit contenir exactement 9 ou 14 chiffres.", "error");
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/sirene', { method: 'POST', body: JSON.stringify({ q: cleanNum }) });
const responseData = await res.json();
if (responseData.status !== 'success') throw new Error(responseData.message);
const apiData = responseData.data;
if (apiData.results && apiData.results.length > 0) {
const company = apiData.results[0];
const expectedSiren = cleanNum.substring(0, 9);
if (company.siren !== expectedSiren) { setIsSaving(false); return showToast("Numéro invalide.", "error"); }
const siege = company.siege || {};
let foundSiret = siege.siret || company.siren + "00010";
if (cleanNum.length === 14) { if (siege.siret === cleanNum) foundSiret = cleanNum; else if (company.matching_etablissements) { const exactEtab = company.matching_etablissements.find(e => e.siret === cleanNum); if (exactEtab) foundSiret = cleanNum; } }
const addressParts = [siege.numero_voie, siege.indice_repetition, siege.type_voie, siege.libelle_voie, siege.code_postal, siege.libelle_commune];
const address = addressParts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() || "Adresse non renseignée";
const tvaKey = (12 + 3 * (parseInt(company.siren, 10) % 97)) % 97;
const calculatedTva = `FR${tvaKey.toString().padStart(2, '0')}${company.siren}`;
setProfileData({ ...profileData, type_client: 'entreprise', name: company.nom_complet, address: address, siret: foundSiret, tva: calculatedTva });
setHasTva(true); showToast("Informations récupérées !", "success");
} else showToast("Aucune entreprise trouvée.", "error");
} catch (err) { showToast("Le service est indisponible.", "error"); }
setIsSaving(false);
}
});
};
const handleToggleTva = () => {
if (isCollaborator) return;
if (hasTva) setShowTvaWarning(true);
else setHasTva(true);
};
const confirmTvaExemption = () => {
setHasTva(false); setProfileData({ ...profileData, tva: '' }); setShowTvaWarning(false);
};
const handleSaveProfile = async () => {
if(!profileData.name.trim() || !profileData.address.trim()) {
return showToast("Veuillez remplir le nom et l'adresse.", "error");
}
if (profileData.type_client === 'entreprise' && !profileData.siret.trim()) {
return showToast("Veuillez remplir le SIRET.", "error");
}
let cleanTva = 'NON_ASSUJETTI';
if (profileData.type_client === 'entreprise' && hasTva) {
if (!profileData.tva || profileData.tva.trim() === '') return showToast("Veuillez renseigner votre numéro de TVA.", "error");
cleanTva = profileData.tva.replace(/\s+/g, '').toUpperCase();
if (!/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(cleanTva)) return showToast("Format de TVA invalide.", "error");
}
setIsSaving(true);
try {
const payload = { ...profileData, tva: cleanTva, uiMode: uiMode };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update', { method: 'POST', body: JSON.stringify(payload) });
if((await res.json()).status === 'success') {
setProfileData({ ...profileData, tva: profileData.type_client === 'entreprise' && hasTva ? cleanTva : '' });
showToast("Profil mis à jour !");
refreshData();
}
} finally { setIsSaving(false); }
};
const handleUiModeChange = async (isAdvanced) => {
const newMode = isAdvanced ? 'professionnel' : 'simplifie';
setUiMode(newMode); setIsSaving(true);
try {
let cleanTva = 'NON_ASSUJETTI';
if (profileData.type_client === 'entreprise' && hasTva && profileData.tva) cleanTva = profileData.tva.replace(/\s+/g, '').toUpperCase();
const payload = { ...profileData, tva: cleanTva, uiMode: newMode };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update', { method: 'POST', body: JSON.stringify(payload) });
if((await res.json()).status === 'success') { showToast(isAdvanced ? "Interface avancée activée." : "Interface simplifiée activée."); refreshData(); }
} catch(e) { showToast("Erreur", "error"); } finally { setIsSaving(false); }
};
const handleSavePassword = async () => {
if(!passwordData.oldPassword || !passwordData.newPassword) return showToast("Champs manquants", "error");
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/password/update', { method: 'POST', body: JSON.stringify({ oldPassword: passwordData.oldPassword, password: passwordData.newPassword }) });
const d = await res.json();
if(d.status === 'success') { showToast("Mot de passe mis à jour"); setPasswordData({oldPassword:'', newPassword:''}); }
else showToast(d.message, 'error');
} finally { setIsSaving(false); }
};
const handleInviteTeam = async () => {
if(!teamEmail) return;
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/invite_team', { method: 'POST', body: JSON.stringify({ email: teamEmail }) });
const d = await res.json();
if(d.status === 'success') { showToast("Invitation envoyée !"); setTeamEmail(''); refreshData(); }
else showToast(d.message, 'error');
} finally { setIsSaving(false); }
};
const requestAccountDeletion = () => {
setPromptDialog({
title: "Suppression du compte", message: "Pour confirmer la suppression de votre compte, veuillez saisir votre mot de passe :", inputType: "password", placeholder: "Mot de passe", confirmText: "Confirmer la suppression",
onConfirm: async (pwd) => {
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/request_delete', { method: 'POST', body: JSON.stringify({ password: pwd }) });
const d = await res.json();
if (d.status === 'success') showToast("E-mail de confirmation envoyé.", "info");
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur", "error"); }
}
});
};
const togglePush = () => {
if (pushEnabled) { localStorage.setItem('eco_push_enabled', 'false'); setPushEnabled(false); showToast("Notifications web désactivées.", "info"); }
else {
if (!window.Notification) return showToast("Votre navigateur ne supporte pas les notifications.", "error");
Notification.requestPermission().then(perm => {
if (perm === 'granted') { localStorage.setItem('eco_push_enabled', 'true'); setPushEnabled(true); showToast("Notifications activées !", "success"); }
else { showToast("Vous avez bloqué les notifications dans votre navigateur.", "error"); }
});
}
};
const enableTotp = async () => {
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/setup_totp', { method: 'POST' });
const d = await res.json();
if (d.status === 'success') {
setTotpSetupData(d.data);
} else {
showToast(d.message, 'error');
}
} catch(e) { showToast("Erreur réseau", "error"); }
setIsSaving(false);
};
const confirmTotp = async () => {
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update_2fa', { method: 'POST', body: JSON.stringify({ method: 'totp' }) });
if ((await res.json()).status === 'success') {
setProfileData({...profileData, two_factor_method: 'totp'});
setTotpSetupData(null);
showToast("2FA par application activée avec succès !", "success");
}
} catch(e) {}
setIsSaving(false);
};
const handleVaultUpload = async (files) => {
if (!files || files.length === 0) return;
let currentTotalSize = managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0;
setIsSaving(true);
let newDocs = [...(managingPanneau.privateDocs || [])];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > 100 * 1024 * 1024) { showToast(`Le fichier ${file.name} dépasse 100 Mo.`, "error"); continue; }
if (currentTotalSize + file.size > 500 * 1024 * 1024) { showToast("La limite totale de 500 Mo du coffre-fort serait dépassée.", "error"); break; }
try {
const type = file.type.includes('pdf') ? 'pdf' : 'image';
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'upload/file', { method: 'POST', body: (() => { const fd = new FormData(); fd.append('file', file); fd.append('type', type); fd.append('is_private', 'true'); return fd; })() });
const d = await res.json();
if (d.status === 'success') {
newDocs.push({ id: d.data.fileId, type, name: file.name, date: new Date().toLocaleDateString('fr-FR'), size: d.data.size || file.size });
currentTotalSize += (d.data.size || file.size);
} else showToast(d.message, "error");
} catch(err) { showToast("Erreur upload", "error"); }
}
const updatedPanneau = {...managingPanneau, privateDocs: newDocs};
setManagingPanneau(updatedPanneau);
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
refreshData(); setIsSaving(false);
};
const deleteVaultDoc = (idx) => {
setConfirmDialog({
title: "Supprimer la preuve", message: "Voulez-vous vraiment supprimer ce document privé ?", isDestructive: true, confirmText: "Supprimer",
onConfirm: async () => {
const newDocs = [...managingPanneau.privateDocs];
newDocs.splice(idx, 1);
const updatedPanneau = {...managingPanneau, privateDocs: newDocs};
setManagingPanneau(updatedPanneau); setIsSaving(true);
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
refreshData(); setIsSaving(false);
}
});
};
const allSimpOptionsActive = data.settings?.simp_opt_description === '1' && data.settings?.simp_opt_image === '1' && data.settings?.simp_opt_theme === '1' && data.settings?.simp_opt_link === '1' && data.settings?.simp_opt_emergency === '1' && data.settings?.simp_opt_schedule === '1';
const isAdvancedMode = uiMode === 'professionnel' || allSimpOptionsActive;
const renderDashboard = () => (
Bienvenue, {myClientData.name || 'Client'} Gérez vos obligations légales BTP en toute simplicité.
{!isSuspended && !isCollaborator && purchasesAllowed && (
{ setManagingPanneau({ name: '', location: '', permitNumber: '', status: 'Brouillon', offerType: 'rental', currentRate: data.prices?.rentalMo ?? 180, physicalPanels: 0, hasNoAds: false, privateDocs: [], shippingAddress: '' }); setValidationErrors([]); setWaiverAccepted(false); setModalStep('config_full'); setIsModalOpen(true); }} className="bg-emerald-600 text-white px-6 py-3 rounded-xl font-black uppercase tracking-widest text-sm hover:bg-emerald-700 transition shadow-lg flex items-center gap-2">
Nouveau panneau
)}
{(!purchasesAllowed) && (
La création de nouvelles commandes est temporairement suspendue.
)}
{isSuspended && (
Facture impayée - compte suspendu
L'accès à l'édition de vos panneaux est temporairement bloqué. Veuillez régulariser votre situation dans l'onglet Factures.
{!isCollaborator &&
setActiveTab('billing')} className="mt-3 bg-red-600 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-red-700 transition">Régulariser maintenant }
)}
} color="text-emerald-600" bg="bg-emerald-100" onClick={() => setActiveTab('panels')} />
} color="text-slate-600" bg="bg-slate-200" onClick={() => setActiveTab('panels')} />
} color="text-blue-600" bg="bg-blue-100" />
} color="text-amber-600" bg="bg-amber-100" onClick={() => setActiveTab('messages')} />
{data.stats?.history && data.stats.history.length > 0 && (
Activité des riverains (30 derniers jours)
)}
);
const renderPanels = () => (
Mes panneaux Gestion de vos affichages légaux.
{!isSuspended && !isCollaborator && purchasesAllowed && (
{ setManagingPanneau({ name: '', location: '', permitNumber: '', status: 'Brouillon', offerType: 'rental', currentRate: data.prices?.rentalMo ?? 180, physicalPanels: 0, hasNoAds: false, privateDocs: [], shippingAddress: '' }); setValidationErrors([]); setWaiverAccepted(false); setModalStep('config_full'); setIsModalOpen(true); }} className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-emerald-700 transition shadow-lg flex items-center gap-2">
Nouveau panneau
)}
{(!purchasesAllowed) && (
La création de nouvelles commandes est temporairement suspendue.
)}
{data.panneaux?.map((c, i) => (
{c.name || 'Brouillon sans nom'}
{c.location || 'Localisation non définie'}
{c.status}
{c.status === 'Actif' && (
Abonnement
{c.offerType === 'rental' ? 'Location' : 'Achat'}
)}
{c.status === 'Actif' && (
<>
setPreviewPanneau(c)} title="Aperçu du panneau" className="flex-1 sm:flex-none justify-center bg-emerald-50 text-emerald-700 px-4 py-2 rounded-xl text-xs font-bold hover:bg-emerald-100 flex items-center gap-2 transition"> Voir le panneau
{ setManagingPanneau(c); setModalStep('share'); setIsModalOpen(true); }} title="Lien de saisie déléguée" className="flex-1 sm:flex-none justify-center bg-blue-50 text-blue-700 px-4 py-2 rounded-xl text-xs font-bold hover:bg-blue-100 flex items-center gap-2 transition"> Saisie déléguée
{ setManagingPanneau(c); setModalStep('legal_vault'); setIsModalOpen(true); }} title="Documents d'huissier" className="flex-1 sm:flex-none justify-center bg-slate-900 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-slate-800 flex items-center gap-2 transition"> Coffre-fort
{!isSuspended && (
{ setManagingPanneau({...c}); setValidationErrors([]); setModalStep('config_full'); setIsModalOpen(true); }} title="Éditer le panneau" className="flex-1 sm:flex-none justify-center bg-white border border-slate-200 text-slate-600 px-4 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 flex items-center gap-2 transition"> Modifier
)}
{!isCollaborator && (
{
setConfirmDialog({
title: "Clôturer le panneau",
message: "Clôturer ce panneau ? Cette action va archiver toutes les données et arrêter la facturation.",
confirmText: "Clôturer et archiver",
isDestructive: true,
onConfirm: () => setArchiveConfig({ type: 'doe', panelId: c.id })
});
}} disabled={archiveConfig !== null} title="Clôturer et archiver le panneau" className="w-full justify-center bg-red-50 text-red-600 px-4 py-2 rounded-xl text-xs font-bold hover:bg-red-100 flex items-center gap-2 transition"> Clôturer et archiver (D.O.E)
)}
>
)}
{c.status === 'Brouillon' && !isSuspended && (
<>
setPreviewPanneau(c)} title="Aperçu du panneau" className="flex-1 sm:flex-none justify-center bg-blue-50 text-blue-700 px-4 py-2 rounded-xl text-xs font-bold hover:bg-blue-100 flex items-center gap-2 transition"> Aperçu
{ setManagingPanneau({...c}); setValidationErrors([]); setModalStep('config_full'); setIsModalOpen(true); }} title="Éditer ce brouillon" className="flex-1 bg-emerald-600 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-emerald-700 flex items-center justify-center gap-2 transition"> Reprendre l'édition
{!isCollaborator &&
{
setConfirmDialog({
title: "Supprimer le brouillon",
message: "Voulez-vous vraiment supprimer ce brouillon ?",
confirmText: "Supprimer",
isDestructive: true,
onConfirm: () => {
fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/delete', { method: 'POST', body: JSON.stringify({id: c.id}) }).then(() => refreshData());
}
});
}} title="Supprimer ce brouillon" className="px-4 py-2 rounded-xl text-xs font-bold text-red-600 bg-red-50 hover:bg-red-100 transition"> }
>
)}
{!isCollaborator && purchasesAllowed && (
{
const deepClone = JSON.parse(JSON.stringify(c));
const cleanIntervenants = (deepClone.intervenants || []).map(i => ({...i, id: crypto.randomUUID(), logoId: '', logoUrl: ''}));
const cleanLots = (deepClone.lots || []).map(l => ({...l, id: crypto.randomUUID(), entreprises: (l.entreprises || []).map(e => ({...e, id: crypto.randomUUID(), logoId: '', logoUrl: ''}))}));
setManagingPanneau(null);
setManagingPanneau({
...c,
id: undefined,
delegate_links: [],
name: c.name + ' (Copie)',
location: '',
permitNumber: '',
status: 'Brouillon',
offerType: 'rental',
physicalPanels: 0,
hasNoAds: false,
privateDocs: [],
shippingAddress: c.shippingAddress || '',
imageId: '',
imageUrl: '',
pdfId: '',
maitreOuvrageLogoId: '',
maitreOuvrageLogoUrl: '',
intervenants: cleanIntervenants,
lots: cleanLots
});
setValidationErrors([]);
setWaiverAccepted(false);
setModalStep('config_full');
setIsModalOpen(true);
}} title="Dupliquer le panneau" className="flex-1 sm:flex-none justify-center bg-white border border-slate-200 text-slate-600 px-4 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 flex items-center gap-2 transition"> Dupliquer
)}
{(!isCollaborator && data.team?.length > 0 && isAdvancedMode) && (
Géré par :
handleAssignPanel(c.id, e.target.value)}
disabled={isSaving}
className="text-xs font-bold text-slate-700 border border-slate-200 rounded-lg px-2 py-1 outline-none bg-slate-50 focus:border-emerald-500 transition"
>
Moi-même (principal)
{data.team.map(m => (
{m.email}
))}
)}
{isCollaborator && (
Assigné à vous
)}
))}
{data.panneaux?.length === 0 && (
Aucun panneau
{isCollaborator ? "Aucun panneau ne vous est assigné." : "Créez votre premier panneau pour démarrer."}
)}
);
const renderLivraisons = () => {
const deliveries = data.panneaux?.filter(c => c.physicalPanels > 0) || [];
const activeDeliveries = deliveries.filter(c => c.shipping_status !== 'Livré');
const historyDeliveries = deliveries.filter(c => c.shipping_status === 'Livré');
const DeliveryCard = ({ c, isHistory }) => {
const isShipped = c.shipping_status === 'Expédié';
const isWaiting = c.shipping_status === 'Validation en cours' || c.shipping_status === "Attente d'impression";
const displayStatus = c.shipping_status === "Attente d'impression" ? "Validée (en attente d'impression)" : c.shipping_status;
return (
{c.name}
{c.shippingAddress || c.location}
Quantité
{c.physicalPanels}
Statut de la commande
{displayStatus}
{c.tracking_number && (
)}
{isWaiting && (
Votre commande est en cours de traitement par nos équipes. Vous recevrez un lien de suivi dès l'expédition.
)}
{isShipped && !isHistory && (
setDeliveryConfirmModal(c.id)} disabled={isSaving} className="w-full bg-emerald-600 text-white py-3 rounded-xl font-bold text-sm hover:bg-emerald-700 transition shadow-md flex justify-center items-center gap-2">
Confirmer la réception
)}
);
};
return (
Livraisons Suivi de vos commandes de panneaux physiques.
{deliveries.length === 0 ? (
Aucune livraison
Vous n'avez pas encore commandé de panneaux physiques.
) : (
<>
{activeDeliveries.length > 0 && (
En cours ({activeDeliveries.length})
{activeDeliveries.map(c => )}
)}
{historyDeliveries.length > 0 && (
Historique ({historyDeliveries.length})
{historyDeliveries.map(c => )}
)}
>
)}
);
};
const renderMessages = () => {
const supportThreadId = `SUPPORT_${myClientData.id}`;
const threadsMap = {
[supportThreadId]: { id: supportThreadId, title: "Support technique eco-panneau", targetEmail: 'Admin', messages: [], unread: 0, isSupport: true }
};
if (isCollaborator) delete threadsMap[supportThreadId];
interactions.forEach(m => {
if (m.panneauId === supportThreadId && !isCollaborator) {
threadsMap[supportThreadId].messages.push(m);
if (m.author === 'Admin' && !m.resolved) threadsMap[supportThreadId].unread++;
} else if (m.panneauId !== supportThreadId) {
const riverainEmail = (m.author !== 'Client' && m.author !== 'Admin') ? m.author : m.target;
if (!riverainEmail) return;
const threadId = `${m.panneauId}_${riverainEmail}`;
const panneauName = activePanneaux.find(c => c.id === m.panneauId)?.name || draftPanneaux.find(c => c.id === m.panneauId)?.name || 'Panneau inconnu';
if (!threadsMap[threadId]) {
threadsMap[threadId] = { id: threadId, panneauId: m.panneauId, title: `${panneauName} - ${riverainEmail}`, targetEmail: riverainEmail, messages: [], unread: 0, isSupport: false };
}
threadsMap[threadId].messages.push(m);
if (m.author !== 'Client' && !m.resolved) threadsMap[threadId].unread++;
}
});
const threads = Object.values(threadsMap)
.filter(t => t.isSupport || t.messages.length > 0)
.sort((a,b) => {
// CORRECTION : Contournement du bug Date Safari avec le wrapper String
const dateA = a.messages.length > 0 ? new Date(String(a.messages[0].created_at || '').replace(' ', 'T')).getTime() : 0;
const dateB = b.messages.length > 0 ? new Date(String(b.messages[0].created_at || '').replace(' ', 'T')).getTime() : 0;
return dateB - dateA;
});
const requestedChatId = new URLSearchParams(window.location.search).get('chat_id');
const selectedThreadId = requestedChatId || (threads[0]?.id);
const setChatId = (id) => {
const u = new URL(window.location);
u.searchParams.set('chat_id', id);
window.history.replaceState({}, '', u);
setMobileChatView(true);
refreshData();
};
const selectedThread = threads.find(t => t.id === selectedThreadId);
const handleSendClient = async (text) => {
if (!selectedThread) return;
const optimisticMsg = {
id: 'temp_' + Date.now(),
panneauId: selectedThread.isSupport ? selectedThread.id : selectedThread.panneauId,
author: 'Client',
target: selectedThread.targetEmail,
detail: text,
isAlert: 0,
resolved: 0,
created_at: new Date().toISOString()
};
window.dispatchEvent(new CustomEvent('optimistic_message', { detail: optimisticMsg }));
const payload = {
panneauId: selectedThread.isSupport ? selectedThread.id : selectedThread.panneauId,
detail: text,
author: 'Client',
targetEmail: selectedThread.targetEmail
};
fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions', { method: 'POST', body: JSON.stringify(payload) });
};
const forceChatMobile = mobileChatView || !!requestedChatId || threads.length === 1;
return (
Conversations
{threads.map(t => (
setChatId(t.id)} className={`w-full text-left p-3 rounded-xl flex items-center justify-between transition ${selectedThreadId === t.id ? 'bg-emerald-50 border border-emerald-200' : 'hover:bg-white border border-transparent'}`}>
{t.title}
{t.messages.length} messages
{t.unread > 0 && {t.unread} }
))}
{threads.length === 0 &&
Aucune conversation.
}
{selectedThread ? (
<>
{threads.length > 1 && (
{
setMobileChatView(false);
const u = new URL(window.location);
u.searchParams.delete('chat_id');
window.history.replaceState({}, '', u);
}} className="md:hidden p-2 -ml-2 text-slate-500 hover:bg-slate-100 rounded-lg transition shrink-0">
)}
{selectedThread.title}
>
) : (
Sélectionnez une conversation
)}
);
};
const renderBilling = () => (
Factures Historique comptable et paiements.
Export CSV
{myClientData.wallet_balance > 0 && (
Solde disponible (Avoir / Crédit)
Ce montant sera automatiquement déduit de vos prochains achats ou abonnements.
{myClientData.wallet_balance.toFixed(2)} €
)}
Date Panneau Détail Montant PDF
{data.invoices?.map((inv, i) => (
{/* CORRECTION : Contournement du bug Date Safari avec le wrapper String */}
{new Date(String(inv.created_at || '').replace(' ', 'T')).toLocaleDateString('fr-FR')}
{inv.panneauName}
{inv.type}
{inv.amount} €
))}
{data.invoices?.length === 0 &&
Aucune facture pour le moment.
}
);
const renderAccount = () => (
Mon compte Paramètres de profil et sécurité.
Profil de la société
{!isCollaborator && (
Saisie automatique
)}
Authentification à double facteur (2FA)
Protégez votre compte avec une étape de sécurité supplémentaire lors de la connexion.
{!isCollaborator && isAdvancedMode && (
Équipe de collaborateurs
Gérez les accès de votre équipe à vos panneaux.
setModalStep('team_manage')} className="w-full bg-slate-100 text-slate-700 py-3 rounded-xl font-bold hover:bg-slate-200 transition flex justify-center items-center gap-2 shadow-sm"> Gérer l'équipe ({data.team?.length || 0})
)}
{/* Bloc Documents Légaux */}
Documents légaux
Consultez nos conditions d'utilisation et notre politique de confidentialité.
setLegalModal('cgv')} className="flex-1 bg-slate-50 border border-slate-200 text-slate-700 px-4 py-3 rounded-xl font-bold text-sm shadow-sm hover:bg-slate-100 transition flex items-center justify-center gap-2">
Conditions générales de vente
setLegalModal('rgpd')} className="flex-1 bg-slate-50 border border-slate-200 text-slate-700 px-4 py-3 rounded-xl font-bold text-sm shadow-sm hover:bg-slate-100 transition flex items-center justify-center gap-2">
Politique de confidentialité
Alertes navigateur
Recevez une alerte visuelle et sonore sur votre PC/Mobile dès qu'un riverain poste un message.
{pushEnabled ? 'Désactiver les alertes' : 'Activer les alertes (Opt-in)'}
{!isCollaborator && (
Données personnelles (RGPD)
Téléchargez une archive complète de vos données personnelles (profil, factures, panneaux, D.O.E, pièces jointes) au format ZIP sécurisé.
setArchiveConfig({ type: 'rgpd' })} disabled={archiveConfig !== null} className="w-full bg-white border border-slate-200 text-slate-700 px-4 py-3 rounded-xl font-bold text-sm shadow-sm hover:bg-slate-50 transition flex items-center justify-center gap-2 disabled:opacity-50">
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).
Supprimer mon compte définitivement
)}
);
return (
Mode avancé
handleUiModeChange(e.target.checked)} disabled={isSaving} />
) : null}
>
{window.ECO_CONFIG.stripePubKey?.startsWith('pk_test_') && (
Mode Test Stripe Actif - Aucun débit réel ne sera effectué lors de vos transactions.
)}
{isLockedForClient && (
Télémaintenance en cours
L'équipe technique intervient sur votre compte. Vos actions sont temporairement restreintes à la messagerie.
{isSaving ? : } Reprendre le contrôle
)}
{isImpersonating && (
Mode administrateur actif
Vous contrôlez actuellement le compte de {myClientData.name} . N'oubliez pas de clôturer l'intervention une fois terminé.
Pause
{isSaving ? : } Clôturer (Fin)
)}
{(!isLockedForClient && activeTab === 'dashboard') && renderDashboard()}
{(!isLockedForClient && activeTab === 'panels') && renderPanels()}
{(!isLockedForClient && activeTab === 'livraisons') && renderLivraisons()}
{activeTab === 'messages' && renderMessages()}
{(!isLockedForClient && activeTab === 'billing') && renderBilling()}
{(!isLockedForClient && activeTab === 'account') && renderAccount()}
{isModalOpen && modalStep.startsWith('config') && (
{ if(e.target === e.currentTarget) document.getElementById('editor-cancel-btn')?.click(); }}>
e.stopPropagation()}>
{managingPanneau.id ? 'Édition du panneau' : 'Nouveau panneau'} Assurez-vous de l'exactitude des informations légales.
document.getElementById('editor-cancel-btn')?.click()} className="text-slate-400 hover:bg-white hover:text-slate-700 p-2 rounded-xl border border-transparent hover:border-slate-200 transition" title="Fermer">
{validationErrors.length > 0 && (
Impossible de publier. Éléments bloquants :
{validationErrors.map((err, idx) => {err} )}
)}
setIsModalOpen(false)}
onSaveDraft={() => handleSavePanneau(true)}
onPublish={
isCollaborator
? (managingPanneau.status === 'Actif' ? () => {
const errs = validatePanel();
if (errs.length > 0) setValidationErrors(errs);
else handleSavePanneau(false);
} : undefined)
: () => {
const errs = validatePanel();
if (errs.length > 0) {
setValidationErrors(errs);
} else {
setValidationErrors([]);
// CALCULER S'IL S'AGIT D'UN NOUVEL ACHAT/UPGRADE SUR UN PANNEAU DEJA ACTIF
const original = data.panneaux.find(p => p.id === managingPanneau.id);
const upgrading = original && original.status === 'Actif' && (
(original.offerType === 'rental' && managingPanneau.offerType === 'purchase') ||
(!original.hasNoAds && managingPanneau.hasNoAds) ||
(managingPanneau.physicalPanels > original.physicalPanels)
);
if (managingPanneau.status === 'Brouillon' || upgrading) {
setWaiverAccepted(false);
setModalStep('select_offer');
} else {
handleSavePanneau(false);
}
}
}
}
onPreview={() => setPreviewPanneau(managingPanneau)}
onEditEntity={(loc, data) => setEditingEntity({location: loc, data})}
draggedItem={draggedItem}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDrop={handleDrop}
deleteEntity={deleteEntity}
isSaving={isSaving}
uiMode={uiMode}
settings={data.settings}
validationErrors={validationErrors}
/>
{editingEntity &&
}
)}
{isModalOpen && modalStep === 'select_offer' && (
Choix de l'offre Sélectionnez le type d'abonnement et les options matérielles.
setModalStep('config_full')} className="text-slate-400 hover:bg-white hover:text-slate-700 p-2 rounded-xl border border-transparent hover:border-slate-200 transition" title="Retour à l'édition">
setManagingPanneau({...managingPanneau, offerType: 'rental'})}
className={`p-6 rounded-2xl border-2 cursor-pointer transition flex flex-col justify-between ${currentOfferType === 'rental' ? 'border-emerald-500 bg-emerald-50 shadow-md' : 'border-slate-200 hover:border-emerald-200'} ${(isActif && originalOfferType === 'purchase') ? 'opacity-50 pointer-events-none' : ''}`}
>
{pRental} € HT / mois
Paiement mensuel automatisé. Résiliable à tout moment, en un clic, à la fin de vos travaux. Engagement mensuel, tout mois entamé est dû.
setManagingPanneau({...managingPanneau, offerType: 'purchase'})}
className={`p-6 rounded-2xl border-2 cursor-pointer transition flex flex-col justify-between ${currentOfferType === 'purchase' ? 'border-emerald-500 bg-emerald-50 shadow-md' : 'border-slate-200 hover:border-emerald-200'}`}
>
{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.
setManagingPanneau({...managingPanneau, physicalPanels: Math.max(originalPanels, currentPanels - 1)})} className="w-10 h-10 flex items-center justify-center rounded-lg bg-white text-slate-600 hover:text-emerald-600 shadow-sm transition font-black text-xl">-
{currentPanels}
setManagingPanneau({...managingPanneau, physicalPanels: currentPanels + 1})} className="w-10 h-10 flex items-center justify-center rounded-lg bg-white text-slate-600 hover:text-emerald-600 shadow-sm transition font-black text-xl">+
{currentPanels > 0 && (
Adresse de livraison
Laissez vide pour utiliser l'adresse de votre société.
)}
{/* Résumé en temps réel */}
Résumé de votre commande
{!isActif && (
<>
{currentOfferType === 'rental' ? 'Abonnement mensuel (1er mois inclus)' : 'Achat définitif'} {(currentOfferType === 'rental' ? pRental : pPurchase).toFixed(2)} € HT
{currentOfferType === 'purchase' &&
Hébergement annuel (1ère année offerte) 0.00 € HT {pHosting.toFixed(2)} €
}
{currentPanels > 0 &&
Panneaux physiques ({currentPanels}x) {(currentPanels > 0 ? pBoardFirst + (currentPanels - 1) * pBoardAdd : 0).toFixed(2)} € HT
}
{currentHasNoAds &&
Option marque blanche (Sans mentions de eco-panneau.fr) {pNoAds.toFixed(2)} € HT
}
>
)}
{isActif && (
<>
{originalOfferType === 'rental' && currentOfferType === 'purchase' &&
Passage à l'achat définitif {pPurchase.toFixed(2)} € HT
}
{currentPanels > originalPanels &&
Panneaux physiques supp. ({currentPanels - originalPanels}x) {addedPanelsCost.toFixed(2)} € HT
}
{currentHasNoAds && !originalHasNoAds &&
Option marque blanche (Sans mentions de eco-panneau.fr) {pNoAds.toFixed(2)} € HT
}
>
)}
{discountPct > 0 && upfrontHT > 0 && (
Remise client ({discountPct}%)
-{(upfrontHT * (discountPct / 100)).toFixed(2)} € HT
)}
{/* NOUVEAU : Affichage de la déduction de la cagnotte */}
{myClientData.wallet_balance > 0 && upfrontHT > 0 && (
Payé avec votre solde (Avoir)
-{Math.min(finalUpfrontTTC, myClientData.wallet_balance).toFixed(2)} € TTC
)}
Reste à payer aujourd'hui
{platformHasTva ? 'TVA 20% incluse' : 'TVA non applicable'}
{Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)).toFixed(2)} €
{currentOfferType === 'rental' &&
Puis {finalMonthlyTTC.toFixed(2)} € TTC / mois
}
{currentOfferType === 'purchase' &&
Puis { (pHosting * priceMultiplier * tvaMult).toFixed(2) } € TTC / an
}
{(!purchasesAllowed && Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)) > 0) && (
L'administration a temporairement suspendu la validation de nouvelles commandes payantes sur la plateforme. Veuillez réessayer ultérieurement.
)}
{Math.max(0, finalUpfrontTTC - (myClientData.wallet_balance || 0)) > 0 && purchasesAllowed && (
setWaiverAccepted(e.target.checked)} className="mt-0.5 w-5 h-5 accent-emerald-600 shrink-0 cursor-pointer" />
J'accepte que l'exécution du service et la fourniture du contenu numérique commencent immédiatement, et je renonce expressément à mon droit de rétractation de 14 jours. (obligatoire)
)}
setModalStep('config_full')} disabled={isSaving} className="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-slate-100 transition disabled:opacity-50">Retour
0 && (!waiverAccepted || !purchasesAllowed))} className="flex-1 bg-emerald-600 text-white py-3 rounded-xl font-black uppercase tracking-widest hover:bg-emerald-700 transition shadow-lg flex items-center justify-center gap-2 disabled:opacity-50">
{isSaving ? : } Confirmer et payer
)}
{isModalOpen && modalStep === 'payment' && paymentData && (
setIsModalOpen(false)} preventClose={isSaving}>
setIsModalOpen(false)} />
)}
{/* MODALE DE CONFIGURATION TOTP 2FA */}
{totpSetupData && (
{ setTotpSetupData(null); setProfileData({...profileData, two_factor_method: 'none'}); }} zIndex="z-[250]">
Scannez ce QR Code avec votre application d'authentification (Google Authenticator, Authy, etc.) :
Ou saisissez cette clé manuellement :
{totpSetupData.secret}
J'ai scanné le code, activer
)}
{/* GESTIONNAIRE DE LIENS DÉLÉGUÉS */}
{isModalOpen && modalStep === 'share' && (
setIsModalOpen(false)}>
Créez des liens sécurisés pour confier la saisie d'informations ou le dépôt de documents (PDF) à d'autres membres de l'équipe du chantier sans leur donner accès à votre compte ou à votre facturation.
{/* Formulaire de création */}
Créer un nouveau lien
Verrouiller la saisie de ces champs (lecture seule) :
{[
{ id: 'name', label: 'Nom du chantier' }, { id: 'location', label: 'Lieu' },
{ id: 'maitreOuvrage', label: "Maître d'ouvrage" }, { id: 'permitNumber', label: 'N° de permis' },
{ id: 'description', label: 'Description' }, { id: 'promoterLink', label: 'Lien promoteur' },
{ id: 'emergencyPhone', label: 'N° d\'urgence' }, { id: 'noiseSchedule', label: 'Horaires' },
{ id: 'intervenants', label: 'Intervenants' }, { id: 'lots', label: 'Lots' }, { id: 'pdfId', label: 'Arrêté (PDF)' }
].map(f => (
setNewLink(p => ({...p, lockedFields: p.lockedFields.includes(f.id) ? p.lockedFields.filter(x=>x!==f.id) : [...p.lockedFields, f.id]}))} className={`px-2 py-1 rounded-lg text-xs font-bold transition border ${newLink.lockedFields.includes(f.id) ? 'bg-red-50 text-red-600 border-red-200' : 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100'}`}>
{newLink.lockedFields.includes(f.id) && }{f.label}
))}
{
if (!newLink.name.trim()) return showToast("Le nom du lien est obligatoire.", "error");
setIsSaving(true);
try {
const linkId = crypto.randomUUID();
const expTime = Math.floor(Date.now() / 1000) + (newLink.expDays * 86400);
const linkObj = { id: linkId, name: newLink.name.trim(), exp: expTime, active: true, lockedFields: newLink.lockedFields };
const updatedPanneau = { ...managingPanneau, delegate_links: [...(managingPanneau.delegate_links || []), linkObj] };
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
setManagingPanneau(updatedPanneau);
setNewLink({ name: '', expDays: 7, lockedFields: [] });
refreshData();
showToast("Nouveau lien de saisie créé.", "success");
} catch(e) { showToast("Erreur", "error"); }
setIsSaving(false);
}} disabled={isSaving || !newLink.name.trim()} className="w-full bg-blue-600 text-white py-2 rounded-lg font-bold text-sm hover:bg-blue-700 transition flex items-center justify-center gap-2">
Créer le lien
{/* Liste des liens existants */}
Liens générés pour ce panneau
{(!managingPanneau.delegate_links || managingPanneau.delegate_links.length === 0) && (
Aucun lien n'a encore été créé pour ce panneau.
)}
{managingPanneau.delegate_links?.map((link, idx) => {
const isExpired = link.exp < Date.now() / 1000;
return (
{link.name}
Exp. : {new Date(link.exp * 1000).toLocaleDateString('fr-FR')} {isExpired && (Expiré) }
{
const newVal = e.target.checked;
setIsSaving(true);
try {
const links = [...managingPanneau.delegate_links];
links[idx] = { ...links[idx], active: newVal };
const updatedPanneau = { ...managingPanneau, delegate_links: links };
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
setManagingPanneau(updatedPanneau); refreshData();
} catch(e) {}
setIsSaving(false);
}} />
{
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux/delegate_link', { method: 'POST', body: JSON.stringify({ id: managingPanneau.id, link_id: link.id }) });
const d = await res.json();
if (d.status === 'success') { navigator.clipboard.writeText(d.data.link); showToast("Lien copié !"); }
} catch(e) { showToast("Erreur", "error"); }
setIsSaving(false);
}} disabled={!link.active || isExpired || isSaving} className="bg-slate-100 text-slate-700 px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-slate-200 transition shadow-sm disabled:opacity-50 flex items-center gap-1">
Copier URL
{
if(!confirm("Supprimer ce lien ?")) return;
setIsSaving(true);
try {
const links = [...managingPanneau.delegate_links];
links.splice(idx, 1);
const updatedPanneau = { ...managingPanneau, delegate_links: links };
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
setManagingPanneau(updatedPanneau); refreshData();
} catch(e) {}
setIsSaving(false);
}} className="text-red-500 hover:bg-red-50 p-1.5 rounded-lg transition" title="Supprimer">
{link.lockedFields && link.lockedFields.length > 0 && (
Bloqué : {link.lockedFields.join(', ')}
)}
);
})}
)}
{isModalOpen && modalStep === 'legal_vault' && (
setIsModalOpen(false)} preventClose={isSaving}>
Preuves et constats d'huissier
Ces documents sont chiffrés et inaccessibles au public. Ils seront inclus dans l'archive D.O.E lors de la clôture du panneau.
{/* Zone de Drag et Drop pour le Coffre-Fort */}
{ e.preventDefault(); setVaultDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setVaultDragging(false); }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
setVaultDragging(false);
const handleVaultUpload = async (files) => {
if (!files || files.length === 0) return;
let currentTotalSize = managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0;
setIsSaving(true);
let newDocs = [...(managingPanneau.privateDocs || [])];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > 100 * 1024 * 1024) { showToast(`Le fichier ${file.name} dépasse 100 Mo.`, "error"); continue; }
if (currentTotalSize + file.size > 500 * 1024 * 1024) { showToast("La limite totale de 500 Mo du coffre-fort serait dépassée.", "error"); break; }
try {
const type = file.type.includes('pdf') ? 'pdf' : 'image';
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'upload/file', { method: 'POST', body: (() => { const fd = new FormData(); fd.append('file', file); fd.append('type', type); fd.append('is_private', 'true'); return fd; })() });
const d = await res.json();
if (d.status === 'success') {
newDocs.push({ id: d.data.fileId, type, name: file.name, date: new Date().toLocaleDateString('fr-FR'), size: d.data.size || file.size });
currentTotalSize += (d.data.size || file.size);
} else showToast(d.message, "error");
} catch(err) { showToast("Erreur upload", "error"); }
}
const updatedPanneau = {...managingPanneau, privateDocs: newDocs};
setManagingPanneau(updatedPanneau);
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
refreshData(); setIsSaving(false);
};
handleVaultUpload(e.dataTransfer.files);
}}
className={`w-full min-h-[150px] border-2 border-dashed rounded-xl flex flex-col items-center justify-center p-6 transition relative ${vaultDragging ? 'border-emerald-500 bg-emerald-50/50' : 'border-slate-300 bg-slate-50 hover:bg-slate-100'}`}
title="Vous pouvez également coller vos fichiers (Ctrl+V) directement sur cette fenêtre."
>
{isSaving ? (
Chiffrement en cours...
) : (
<>
Glissez-déposez vos preuves ici (ou Ctrl+V)
Images ou PDF (Max 100 Mo/fichier, 500 Mo au total)
Parcourir les fichiers
{
const files = e.target.files;
if(!files) return;
const handleVaultUpload = async (files) => {
if (!files || files.length === 0) return;
let currentTotalSize = managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0;
setIsSaving(true);
let newDocs = [...(managingPanneau.privateDocs || [])];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > 100 * 1024 * 1024) { showToast(`Le fichier ${file.name} dépasse 100 Mo.`, "error"); continue; }
if (currentTotalSize + file.size > 500 * 1024 * 1024) { showToast("La limite totale de 500 Mo du coffre-fort serait dépassée.", "error"); break; }
try {
const type = file.type.includes('pdf') ? 'pdf' : 'image';
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'upload/file', { method: 'POST', body: (() => { const fd = new FormData(); fd.append('file', file); fd.append('type', type); fd.append('is_private', 'true'); return fd; })() });
const d = await res.json();
if (d.status === 'success') {
newDocs.push({ id: d.data.fileId, type, name: file.name, date: new Date().toLocaleDateString('fr-FR'), size: d.data.size || file.size });
currentTotalSize += (d.data.size || file.size);
} else showToast(d.message, "error");
} catch(err) { showToast("Erreur upload", "error"); }
}
const updatedPanneau = {...managingPanneau, privateDocs: newDocs};
setManagingPanneau(updatedPanneau);
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
refreshData(); setIsSaving(false);
};
handleVaultUpload(files);
e.target.value = null;
}} />
>
)}
{/* Affichage Vignettes du Coffre-Fort */}
{managingPanneau.privateDocs && managingPanneau.privateDocs.length > 0 ? (
{managingPanneau.privateDocs.map((doc, idx) => (
setViewingDoc(doc)}>
{doc.type === 'image' ? (
) : (
PDF
)}
{doc.name}
{doc.date}
{
e.stopPropagation();
setConfirmDialog({
title: "Supprimer la preuve", message: "Voulez-vous vraiment supprimer ce document privé ?", isDestructive: true, confirmText: "Supprimer",
onConfirm: async () => {
const newDocs = [...managingPanneau.privateDocs]; newDocs.splice(idx, 1);
const updatedPanneau = {...managingPanneau, privateDocs: newDocs};
setManagingPanneau(updatedPanneau); setIsSaving(true);
await fetch(window.ECO_CONFIG.apiBaseUrl + 'panneaux', { method: 'POST', body: JSON.stringify({ id: updatedPanneau.id, status: updatedPanneau.status, offerType: updatedPanneau.offerType, physicalPanels: updatedPanneau.physicalPanels, details: updatedPanneau }) });
refreshData(); setIsSaving(false);
}
});
}} className="text-red-400 hover:text-red-600 bg-red-50 hover:bg-red-100 p-1 rounded transition" title="Supprimer">
))}
) : (
Aucun document privé stocké.
)}
Total utilisé : {((managingPanneau.privateDocs?.reduce((acc, doc) => acc + (doc.size || 0), 0) || 0) / (1024*1024)).toFixed(2)} Mo / 500 Mo
)}
{/* Visualiseur Plein Écran Coffre-Fort (Remplacement Iframe -> PdfFullViewer) */}
{viewingDoc && (
setViewingDoc(null)}>
e.stopPropagation()}>
setViewingDoc(null)} className="flex items-center gap-2 px-4 py-2 hover:bg-white/10 rounded-xl transition text-sm font-bold">
Retour
{viewingDoc.type === 'image' ? (
e.stopPropagation()} />
) : (
)}
)}
{/* Modale de Confirmation de Livraison */}
{deliveryConfirmModal && (
setDeliveryConfirmModal(null)} preventClose={isSaving}>
Colis livré ?
En confirmant, vous indiquez avoir bien reçu l'intégralité de votre commande de panneaux physiques pour ce panneau.
setDeliveryConfirmModal(null)}
disabled={isSaving}
className="w-full sm:flex-1 bg-slate-100 text-slate-700 py-3 rounded-xl font-bold hover:bg-slate-200 transition disabled:opacity-50"
>
Annuler
{isSaving ? (
) : (
)}
Oui, j'ai reçu mon colis
)}
{/* MODALE DOCUMENTS LÉGAUX (CGV / RGPD) */}
{legalModal && (
setLegalModal(null)} zIndex="z-[250]">
/g, '>').replace(/"/g, '"').replace(/'/g, ''')
}}
className="text-sm text-slate-600 leading-relaxed font-medium admin-html"
/>
)}
{/* Modales de Confirmation et Prompt Universelles */}
{confirmDialog && setConfirmDialog(null)} />}
{promptDialog && setPromptDialog(null)} />}
{previewPanneau && setPreviewPanneau(null)} showToast={showToast} refreshData={refreshData} />}
{/* MODALE GLOBALE DE GENERATION D'ARCHIVES */}
{archiveConfig && (
setArchiveConfig(null)}
showToast={showToast}
refreshData={refreshData}
/>
)}
);
};
/* EOF ===== [_clients.jsx] =============== */