/**
* =========================================================================
* PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0
* Interface Client B2B (Tableau de bord, Facturation, Gestion des panneaux)
* =========================================================================
*/
const { useState, useEffect } = React;
const {
Home, Building, MessageSquare, FileText, UserCircle, Plus, Search,
CheckCircle, AlertTriangle, Shield, Copy, Archive, Trash2, Edit, Eye,
CreditCard, Download, Loader, LogOut, ArrowLeft, KeyRound,
Lock, ShieldCheck, Mail, Users, Bell, Power, Settings, FileCheck, Image, X, Zap, Package, Share2, RefreshCw, MapPin, ExternalLink
} = window;
// =========================================================================
// 1. COMPOSANT STRIPE : FORMULAIRE DE PAIEMENT
// =========================================================================
const StripePaymentForm = ({ clientSecret, onSuccess, onCancel, amountCents }) => {
const stripe = window.Stripe(window.ECO_CONFIG.stripePubKey);
const options = { clientSecret, appearance: { theme: 'stripe', variables: { colorPrimary: '#10b981', borderRadius: '12px' } } };
const CheckoutForm = () => {
const stripeInstance = window.ReactStripe.useStripe();
const elements = window.ReactStripe.useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!stripeInstance || !elements) return;
setIsProcessing(true); setError(null);
const { error: submitError } = await elements.submit();
if (submitError) { setError(submitError.message); setIsProcessing(false); return; }
const { error: confirmError, paymentIntent } = await stripeInstance.confirmPayment({
elements, confirmParams: { return_url: window.location.href }, redirect: "if_required"
});
if (confirmError) { setError(confirmError.message); setIsProcessing(false); }
else if (paymentIntent && paymentIntent.status === "succeeded") { onSuccess(paymentIntent.id); }
else { setError("Statut de paiement inattendu."); setIsProcessing(false); }
};
return (
);
};
return (
);
};
// =========================================================================
// 2. VUE PRINCIPALE DU CLIENT B2B
// =========================================================================
window.ClientView = ({ data, refreshData, showToast }) => {
const [activeTab, setActiveTab] = useState('dashboard');
const [uiMode, setUiMode] = useState('simplifie');
const [pushEnabled, setPushEnabled] = useState(localStorage.getItem('eco_push_enabled') === 'true' && window.Notification?.permission === 'granted');
const [managingChantier, setManagingChantier] = useState(null);
const [editingEntity, setEditingEntity] = useState(null);
const [draggedItem, setDraggedItem] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalStep, setModalStep] = useState('config_full');
const [validationErrors, setValidationErrors] = useState([]);
const [delegateLink, setDelegateLink] = useState('');
const [paymentData, setPaymentData] = useState(null);
const [previewChantier, setPreviewChantier] = useState(null);
const [profileData, setProfileData] = useState({ name: '', address: '', siret: '', tva: '', monthlyReport: 0 });
const [hasTva, setHasTva] = useState(true);
const [showTvaWarning, setShowTvaWarning] = useState(false);
const [passwordData, setPasswordData] = useState({ oldPassword: '', newPassword: '' });
const [teamEmail, setTeamEmail] = useState('');
const [meRes, setMeRes] = useState(null);
useEffect(() => {
fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/me').then(r=>r.json()).then(d=>setMeRes(d.data));
}, []);
const myClientData = data.clients?.find(c => c.id === meRes?.user_id) || data.clients?.[0] || {};
const isSuspended = myClientData.paymentStatus === 'suspended';
const isCollaborator = !data.team;
const isImpersonating = meRes?.is_impersonating;
useEffect(() => {
if (myClientData.uiMode) setUiMode(myClientData.uiMode);
const isTvaExempt = myClientData.tva === 'NON_ASSUJETTI';
setHasTva(!isTvaExempt);
setProfileData({
name: myClientData.name || '', address: myClientData.address || '',
siret: myClientData.siret || '', tva: isTvaExempt ? '' : (myClientData.tva || ''),
monthlyReport: myClientData.monthlyReport || 0
});
}, [myClientData]);
// Interception du lien magique de validation de réception (depuis l'e-mail logistique)
useEffect(() => {
const url = new URL(window.location);
const confirmDelivery = url.searchParams.get('confirm_delivery');
if (confirmDelivery) {
setActiveTab('livraisons');
if (window.confirm("Avez-vous bien reçu vos panneaux pour ce chantier ?")) {
setIsSaving(true);
fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/shipping', {
method: 'POST',
body: JSON.stringify({ id: confirmDelivery, shipping_status: 'Livré' })
}).then(res => res.json()).then(d => {
if (d.status === 'success') {
showToast("Réception confirmée, merci !");
refreshData();
}
}).finally(() => setIsSaving(false));
}
url.searchParams.delete('confirm_delivery');
window.history.replaceState({}, '', url);
}
}, []);
const activeChantiers = data.chantiers?.filter(c => c.status === 'Actif') || [];
const draftChantiers = data.chantiers?.filter(c => c.status === 'Brouillon') || [];
const interactions = data.interactions || [];
const totalUnread = interactions.filter(m => !m.resolved && m.author !== 'Client').length;
const activeDeliveries = data.chantiers?.filter(c => c.physicalPanels > 0 && c.shipping_status !== 'Livré') || [];
const handleLogout = async () => {
await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/logout');
window.location.href = '?';
};
const handleUnimpersonate = async () => {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'auth/unimpersonate', { method: 'POST' });
const d = await res.json();
if (d.status === 'success') { window.location.href = '?'; } else { showToast(d.message, 'error'); }
};
const customLogout = isImpersonating ? {
label: "Retour Admin",
icon: ,
action: () => handleUnimpersonate()
} : null;
const navItems = [
{ id: 'dashboard', icon: , label: 'Tableau de bord', badge: 0 },
{ id: 'panels', icon: , label: 'Mes chantiers', badge: 0 },
{ id: 'livraisons', icon: , label: 'Livraisons', badge: activeDeliveries.length },
{ id: 'messages', icon: , label: 'Messagerie', badge: totalUnread },
...(isCollaborator ? [] : [{ id: 'billing', icon: , label: 'Factures', badge: 0 }]),
{ id: 'account', icon: , label: 'Mon compte', badge: 0 },
];
const saveEditedEntity = () => {
let newInter = [...(managingChantier.intervenants || [])];
let newLots = JSON.parse(JSON.stringify(managingChantier.lots || []));
const loc = editingEntity.location;
if (loc.type === 'intervenant') {
if (loc.index !== undefined) newInter[loc.index] = editingEntity.data;
else newInter.push({...editingEntity.data, id: crypto.randomUUID()});
} else if (loc.type === 'lot') {
if (loc.index !== undefined) newLots[loc.index] = {...newLots[loc.index], ...editingEntity.data};
else newLots.push({...editingEntity.data, id: crypto.randomUUID(), entreprises: []});
} else if (loc.type === 'entreprise') {
if (loc.index !== undefined) newLots[loc.lotIndex].entreprises[loc.index] = editingEntity.data;
else newLots[loc.lotIndex].entreprises.push({...editingEntity.data, id: crypto.randomUUID()});
}
setManagingChantier({ ...managingChantier, intervenants: newInter, lots: newLots });
setEditingEntity(null);
};
const deleteEntity = (loc) => {
if (!confirm("Supprimer cet élément ?")) return;
let newInter = [...(managingChantier.intervenants || [])];
let newLots = JSON.parse(JSON.stringify(managingChantier.lots || []));
if (loc.type === 'intervenant') newInter.splice(loc.index, 1);
else if (loc.type === 'lot') newLots.splice(loc.index, 1);
else newLots[loc.lotIndex].entreprises.splice(loc.index, 1);
setManagingChantier({ ...managingChantier, intervenants: newInter, lots: newLots });
};
const handleDragStart = (e, location) => { setDraggedItem(location); e.dataTransfer.effectAllowed = 'move'; };
const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
const handleDrop = (e, dropLocation) => {
e.preventDefault();
if (!draggedItem || draggedItem.type !== dropLocation.type) return;
if (draggedItem.type === 'entreprise' && draggedItem.lotIndex !== dropLocation.lotIndex) return;
let newInter = [...(managingChantier.intervenants || [])];
let newLots = JSON.parse(JSON.stringify(managingChantier.lots || []));
if (draggedItem.type === 'intervenant') {
const item = newInter.splice(draggedItem.index, 1)[0];
newInter.splice(dropLocation.index, 0, item);
} else if (draggedItem.type === 'lot') {
const item = newLots.splice(draggedItem.index, 1)[0];
newLots.splice(dropLocation.index, 0, item);
} else if (draggedItem.type === 'entreprise') {
const arr = newLots[dropLocation.lotIndex].entreprises;
const item = arr.splice(draggedItem.index, 1)[0];
arr.splice(dropLocation.index, 0, item);
}
setManagingChantier({ ...managingChantier, intervenants: newInter, lots: newLots });
setDraggedItem(null);
};
const validatePanel = () => {
const errors = [];
if (!managingChantier.name?.trim()) errors.push("Le nom du chantier est manquant.");
if (!managingChantier.location?.trim()) errors.push("L'adresse du chantier est manquante.");
if (!managingChantier.pdfId) errors.push("L'arrêté légal (PDF) n'a pas été uploadé.");
if (!isCollaborator) {
if (!profileData.name?.trim()) errors.push("Facturation : raison sociale manquante (onglet Mon compte).");
if (!profileData.address?.trim()) errors.push("Facturation : adresse postale manquante (onglet Mon compte).");
if (!profileData.siret?.trim()) errors.push("Facturation : SIRET de l'entreprise manquant (onglet Mon compte).");
}
return errors;
};
const handleSaveChantier = async (forceDraft = false) => {
if (!forceDraft && managingChantier.status === 'Actif') {
const errs = validatePanel();
if (errs.length > 0) { setValidationErrors(errs); return; }
}
setIsSaving(true);
try {
const payload = {
id: managingChantier.id,
status: managingChantier.status,
offerType: managingChantier.offerType,
currentRate: managingChantier.currentRate,
physicalPanels: managingChantier.physicalPanels,
details: { ...managingChantier, name: managingChantier.name?.trim() || 'Brouillon sans nom', location: managingChantier.location?.trim() || '' }
};
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers', { method: 'POST', body: JSON.stringify(payload) });
const d = await res.json();
if(d.status === 'success') { showToast(forceDraft ? "Brouillon sauvegardé !" : "Chantier mis à jour !"); setIsModalOpen(false); refreshData(); }
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur de sauvegarde", "error"); }
setIsSaving(false);
};
const handleActivationRequest = async () => {
if(isSuspended) return showToast("Votre compte est suspendu. Veuillez régulariser vos factures.", "error");
setIsSaving(true);
try {
const payload = { chantier_id: managingChantier.id, offer_type: managingChantier.offerType, physical_panels: managingChantier.physicalPanels, hasNoAds: managingChantier.hasNoAds };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'stripe/intent', { method: 'POST', body: JSON.stringify(payload) });
const d = await res.json();
if(d.status === 'success') {
if(d.data.free_activation_required) {
const freeRes = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/activate_free', { method: 'POST', body: JSON.stringify({ ...payload, id: managingChantier.id, offerType: managingChantier.offerType, physicalPanels: managingChantier.physicalPanels, details: managingChantier, name: managingChantier.name }) });
if((await freeRes.json()).status === 'success') { showToast("Activation gratuite réussie !"); setIsModalOpen(false); refreshData(); }
else showToast("Erreur d'activation", "error");
} else {
setPaymentData({ secret: d.data.clientSecret, subId: d.data.subscription_id, amountCents: d.data.amountCents });
setModalStep('payment');
}
} else showToast(d.message, 'error');
} catch(e) { showToast("Erreur de communication Stripe", "error"); }
setIsSaving(false);
};
const handlePaymentSuccess = async (pi_id) => {
setIsSaving(true);
try {
const payload = { payment_intent_id: pi_id, subscription_id: paymentData?.subId, id: managingChantier.id, offerType: managingChantier.offerType, currentRate: managingChantier.currentRate, physicalPanels: managingChantier.physicalPanels, details: managingChantier, name: managingChantier.name };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/activate_paid', { method: 'POST', body: JSON.stringify(payload) });
const d = await res.json();
if(d.status === 'success') { showToast("Paiement validé ! Panneau activé."); setIsModalOpen(false); refreshData(); }
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur", "error"); }
setIsSaving(false);
};
const generateDelegateLink = async (id) => {
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/delegate_link', { method: 'POST', body: JSON.stringify({ id }) });
const d = await res.json();
if(d.status === 'success') { setDelegateLink(d.data.link); setModalStep('share'); setIsModalOpen(true); }
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur réseau", "error"); }
};
const handleDeleteTeamMember = async (uid) => {
if (!confirm("Voulez-vous vraiment supprimer ce collaborateur ? Ses accès seront révoqués et ses panneaux vous seront réassignés.")) return;
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/team/delete', { method: 'POST', body: JSON.stringify({ uid }) });
if ((await res.json()).status === 'success') { showToast("Collaborateur supprimé."); refreshData(); }
} finally { setIsSaving(false); }
};
const handleAssignPanel = async (chantierId, assignedUid) => {
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/assign', { method: 'POST', body: JSON.stringify({ chantier_id: chantierId, assigned_uid: assignedUid }) });
if ((await res.json()).status === 'success') { showToast("Assignation mise à jour."); refreshData(); }
} finally { setIsSaving(false); }
};
const handleAutoFill = async () => {
const num = prompt("Saisissez votre numéro SIRET (14 chiffres) ou SIREN (9 chiffres) :");
if (!num) return;
const cleanNum = num.replace(/\D/g, '');
if (cleanNum.length !== 9 && cleanNum.length !== 14) {
return showToast("Le numéro doit contenir exactement 9 ou 14 chiffres.", "error");
}
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'system/sirene', {
method: 'POST',
body: JSON.stringify({ q: cleanNum })
});
const responseData = await res.json();
if (responseData.status !== 'success') throw new Error(responseData.message);
const apiData = responseData.data;
if (apiData.results && apiData.results.length > 0) {
const company = apiData.results[0];
const expectedSiren = cleanNum.substring(0, 9);
if (company.siren !== expectedSiren) {
setIsSaving(false);
return showToast("Numéro invalide : Aucune entreprise correspondante.", "error");
}
const siege = company.siege || {};
const siren = company.siren;
let foundSiret = siege.siret || siren + "00010";
if (cleanNum.length === 14) {
if (siege.siret === cleanNum) foundSiret = cleanNum;
else if (company.matching_etablissements) {
const exactEtab = company.matching_etablissements.find(e => e.siret === cleanNum);
if (exactEtab) foundSiret = cleanNum;
}
}
const addressParts = [siege.numero_voie, siege.indice_repetition, siege.type_voie, siege.libelle_voie, siege.code_postal, siege.libelle_commune];
const address = addressParts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() || "Adresse non renseignée";
const tvaKey = (12 + 3 * (parseInt(siren, 10) % 97)) % 97;
const calculatedTva = `FR${tvaKey.toString().padStart(2, '0')}${siren}`;
setProfileData({ ...profileData, name: company.nom_complet, address: address, siret: foundSiret, tva: calculatedTva });
setHasTva(true);
showToast("Informations récupérées avec succès !", "success");
} else {
showToast("Aucune entreprise trouvée.", "error");
}
} catch (err) {
console.error("Détail de l'erreur SIRENE :", err);
showToast("Le service gouvernemental est momentanément indisponible.", "error");
}
setIsSaving(false);
};
const handleToggleTva = () => {
if (isCollaborator) return;
if (hasTva) setShowTvaWarning(true);
else setHasTva(true);
};
const confirmTvaExemption = () => {
setHasTva(false);
setProfileData({ ...profileData, tva: '' });
setShowTvaWarning(false);
};
const handleSaveProfile = async () => {
if(!profileData.name.trim() || !profileData.address.trim() || !profileData.siret.trim()) {
return showToast("Veuillez remplir le nom, l'adresse et le SIRET.", "error");
}
let cleanTva = 'NON_ASSUJETTI';
if (hasTva) {
if (!profileData.tva || profileData.tva.trim() === '') return showToast("Veuillez renseigner votre numéro de TVA.", "error");
cleanTva = profileData.tva.replace(/\s+/g, '').toUpperCase();
if (!/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(cleanTva)) return showToast("Format de TVA invalide.", "error");
if (cleanTva.startsWith('FR')) {
if (!/^FR[0-9A-Z]{2}[0-9]{9}$/.test(cleanTva)) return showToast("Format de TVA française invalide.", "error");
if (profileData.siret && profileData.siret.length >= 9) {
const sirenBase = profileData.siret.replace(/\s+/g, '').substring(0, 9);
const tvaSiren = cleanTva.substring(4);
if (sirenBase !== tvaSiren) return showToast("La TVA ne correspond pas au SIRET.", "error");
}
}
}
setIsSaving(true);
try {
const payload = { ...profileData, tva: cleanTva, uiMode: uiMode };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update', { method: 'POST', body: JSON.stringify(payload) });
if((await res.json()).status === 'success') {
setProfileData({ ...profileData, tva: hasTva ? cleanTva : '' });
showToast("Profil mis à jour et validé !");
refreshData();
}
} finally { setIsSaving(false); }
};
const handleUiModeChange = async (isAdvanced) => {
const newMode = isAdvanced ? 'professionnel' : 'simplifie';
setUiMode(newMode);
setIsSaving(true);
try {
let cleanTva = 'NON_ASSUJETTI';
if (hasTva && profileData.tva) cleanTva = profileData.tva.replace(/\s+/g, '').toUpperCase();
const payload = { ...profileData, tva: cleanTva, uiMode: newMode };
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/profile/update', { method: 'POST', body: JSON.stringify(payload) });
if((await res.json()).status === 'success') {
showToast(isAdvanced ? "Interface avancée activée." : "Interface simplifiée activée.");
refreshData();
}
} catch(e) {
showToast("Erreur lors de la sauvegarde du mode", "error");
} finally { setIsSaving(false); }
};
const handleSavePassword = async () => {
if(!passwordData.oldPassword || !passwordData.newPassword) return showToast("Champs manquants", "error");
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/password/update', { method: 'POST', body: JSON.stringify({ oldPassword: passwordData.oldPassword, password: passwordData.newPassword }) });
const d = await res.json();
if(d.status === 'success') { showToast("Mot de passe mis à jour"); setPasswordData({oldPassword:'', newPassword:''}); }
else showToast(d.message, 'error');
} finally { setIsSaving(false); }
};
const handleInviteTeam = async () => {
if(!teamEmail) return;
setIsSaving(true);
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/invite_team', { method: 'POST', body: JSON.stringify({ email: teamEmail }) });
const d = await res.json();
if(d.status === 'success') { showToast("Invitation envoyée !"); setTeamEmail(''); refreshData(); }
else showToast(d.message, 'error');
} finally { setIsSaving(false); }
};
const requestAccountDeletion = async () => {
const pwd = prompt("Pour confirmer la suppression de votre compte, veuillez saisir votre mot de passe :");
if (!pwd) return;
try {
const res = await fetch(window.ECO_CONFIG.apiBaseUrl + 'clients/request_delete', { method: 'POST', body: JSON.stringify({ password: pwd }) });
const d = await res.json();
if (d.status === 'success') showToast("E-mail de confirmation envoyé.", "info");
else showToast(d.message, 'error');
} catch(e) { showToast("Erreur", "error"); }
};
const togglePush = () => {
if (pushEnabled) {
localStorage.setItem('eco_push_enabled', 'false'); setPushEnabled(false); showToast("Notifications web désactivées.", "info");
} else {
if (!window.Notification) return showToast("Votre navigateur ne supporte pas les notifications.", "error");
Notification.requestPermission().then(perm => {
if (perm === 'granted') { localStorage.setItem('eco_push_enabled', 'true'); setPushEnabled(true); showToast("Notifications activées !", "success"); }
else { showToast("Vous avez bloqué les notifications dans votre navigateur.", "error"); }
});
}
};
const sidebarFooter = !isCollaborator ? (
Mode avancé
handleUiModeChange(e.target.checked)} disabled={isSaving} />
) : null;
const renderDashboard = () => (
Bienvenue, {myClientData.name || 'Client'} Gérez vos obligations légales BTP en toute simplicité.
{!isSuspended && !isCollaborator && (
{ setManagingChantier({ name: '', location: '', permitNumber: '', status: 'Brouillon', offerType: 'rental', currentRate: data.prices?.rentalMo || 180, physicalPanels: 0, hasNoAds: false, privateDocs: [] }); setValidationErrors([]); 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 chantier
)}
{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')} />
Fréquentation (30j)
Activité récente
{interactions.slice(0, 5).map((m, i) => (
setActiveTab('messages')} className="flex items-start gap-3 p-3 rounded-xl hover:bg-slate-50 cursor-pointer transition border border-transparent hover:border-slate-100">
))}
{interactions.length === 0 &&
Aucune activité récente.
}
);
const renderPanels = () => (
Mes chantiers Gestion de vos affichages légaux.
{!isSuspended && !isCollaborator &&
{ setManagingChantier({ name: '', location: '', permitNumber: '', status: 'Brouillon', offerType: 'rental', currentRate: data.prices?.rentalMo || 180, physicalPanels: 0, hasNoAds: false, privateDocs: [] }); setValidationErrors([]); 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 chantier }
{data.chantiers?.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' && (
<>
setPreviewChantier(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
generateDelegateLink(c.id)} title="Lien de saisie pour architecte" 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"> Délégué
{ setManagingChantier(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 && (
{ setManagingChantier({...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 && (
{
if (confirm("Clôturer ce panneau ? Cette action va archiver toutes les données et arrêter la facturation.")) {
setIsSaving(true);
fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/archive', { method: 'POST', body: JSON.stringify({id: c.id}) })
.then(res => res.json())
.then(d => {
if(d.status === 'success') {
window.location.href = d.data.archive_url;
showToast("Archive générée et panneau clôturé !");
refreshData();
} else showToast(d.message, 'error');
}).finally(() => setIsSaving(false));
}
}} disabled={isSaving} title="Clôturer et archiver le chantier" 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 && (
<>
setPreviewChantier(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
{ setManagingChantier({...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 &&
{ if(confirm("Supprimer ce brouillon ?")) { fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/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 && (
{
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: ''}))}));
setManagingChantier(null);
setManagingChantier({
...c,
id: undefined,
name: c.name + ' (Copie)',
location: '',
permitNumber: '',
status: 'Brouillon',
offerType: 'rental',
physicalPanels: 0,
hasNoAds: false,
privateDocs: [],
imageId: '',
imageUrl: '',
pdfId: '',
maitreOuvrageLogoId: '',
maitreOuvrageLogoUrl: '',
intervenants: cleanIntervenants,
lots: cleanLots
});
setValidationErrors([]);
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 && uiMode === 'professionnel') && (
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.chantiers?.length === 0 && (
Aucun chantier
{isCollaborator ? "Aucun chantier ne vous est assigné." : "Créez votre premier chantier pour démarrer."}
{!isSuspended && !isCollaborator &&
{ setManagingChantier({ name: '', location: '', permitNumber: '', status: 'Brouillon', offerType: 'rental', currentRate: data.prices?.rentalMo || 180, physicalPanels: 0, hasNoAds: false, privateDocs: [] }); setValidationErrors([]); setModalStep('config_full'); setIsModalOpen(true); }} className="mt-6 bg-emerald-600 text-white px-6 py-3 rounded-xl font-bold shadow-lg hover:bg-emerald-700 transition">Créer un chantier }
)}
);
const renderLivraisons = () => {
const deliveries = data.chantiers?.filter(c => c.physicalPanels > 0) || [];
const activeDeliveries = deliveries.filter(c => c.shipping_status !== 'Livré');
const historyDeliveries = deliveries.filter(c => c.shipping_status === 'Livré');
const DeliveryCard = ({ c, isHistory }) => {
const isShipped = c.shipping_status === 'Expédié';
const isWaiting = c.shipping_status === 'Validation en cours' || c.shipping_status === "Attente d'impression";
const displayStatus = c.shipping_status === "Attente d'impression" ? "Validée (en attente d'impression)" : c.shipping_status;
return (
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 && (
{
if(window.confirm("Confirmez-vous la bonne réception de ces panneaux ?")) {
setIsSaving(true);
fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers/shipping', { method: 'POST', body: JSON.stringify({ id: c.id, shipping_status: 'Livré' }) })
.then(res => res.json()).then(() => { showToast("Réception confirmée !"); refreshData(); }).finally(() => setIsSaving(false));
}
}} 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.
Actualiser
{deliveries.length === 0 ? (
Aucune livraison
Vous n'avez pas encore commandé de panneaux physiques.
) : (
<>
{activeDeliveries.length > 0 && (
En cours ({activeDeliveries.length})
{activeDeliveries.map(c => )}
)}
{historyDeliveries.length > 0 && (
Historique ({historyDeliveries.length})
{historyDeliveries.map(c => )}
)}
>
)}
);
};
const renderMessages = () => {
const supportThreadId = `SUPPORT_${myClientData.id}`;
const threadsMap = {
[supportThreadId]: { id: supportThreadId, title: "Support technique eco-panneau", targetEmail: 'Admin', messages: [], unread: 0, isSupport: true }
};
if (isCollaborator) delete threadsMap[supportThreadId];
interactions.forEach(m => {
if (m.chantierId === supportThreadId && !isCollaborator) {
threadsMap[supportThreadId].messages.push(m);
if (m.author === 'Admin' && !m.resolved) threadsMap[supportThreadId].unread++;
} else if (m.chantierId !== supportThreadId) {
const riverainEmail = (m.author !== 'Client' && m.author !== 'Admin') ? m.author : m.target;
if (!riverainEmail) return;
const threadId = `${m.chantierId}_${riverainEmail}`;
const panelName = activeChantiers.find(c => c.id === m.chantierId)?.name || draftChantiers.find(c => c.id === m.chantierId)?.name || 'Panneau inconnu';
if (!threadsMap[threadId]) {
threadsMap[threadId] = { id: threadId, chantierId: m.chantierId, title: `${panelName} - ${riverainEmail}`, targetEmail: riverainEmail, messages: [], unread: 0, isSupport: false };
}
threadsMap[threadId].messages.push(m);
if (m.author !== 'Client' && !m.resolved) threadsMap[threadId].unread++;
}
});
const threads = Object.values(threadsMap)
.filter(t => t.isSupport || t.messages.length > 0)
.sort((a,b) => {
const dateA = a.messages.length > 0 ? new Date(a.messages[0].created_at) : new Date(0);
const dateB = b.messages.length > 0 ? new Date(b.messages[0].created_at) : new Date(0);
return dateB - dateA;
});
const selectedThreadId = new URLSearchParams(window.location.search).get('chat_id') || threads[0]?.id;
const setChatId = (id) => { const u = new URL(window.location); u.searchParams.set('chat_id', id); window.history.replaceState({}, '', u); refreshData(); };
const selectedThread = threads.find(t => t.id === selectedThreadId);
const handleSendClient = async (text) => {
if (!selectedThread) return;
const payload = {
chantierId: selectedThread.isSupport ? selectedThread.id : selectedThread.chantierId,
detail: text,
author: 'Client',
targetEmail: selectedThread.targetEmail
};
await fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions', { method: 'POST', body: JSON.stringify(payload) });
refreshData();
};
return (
Conversations
{threads.map(t => (
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 ? (
<>
{selectedThread.title}
>
) : (
Sélectionnez une conversation
)}
);
};
const renderBilling = () => (
Factures Historique comptable et paiements.
Export CSV
Date Chantier Détail Montant PDF
{data.invoices?.map((inv, i) => (
{new Date(inv.created_at).toLocaleDateString('fr-FR')}
{inv.chantierName}
{inv.type}
{inv.amount} €
))}
{data.invoices?.length === 0 &&
Aucune facture pour le moment.
}
);
const renderAccount = () => (
Mon compte Paramètres de profil et sécurité.
Profil de la société
{!isCollaborator && (
Saisie automatique
)}
Nom / raison sociale * setProfileData({...profileData, name: e.target.value})} disabled={isCollaborator} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition disabled:bg-slate-50" />
Adresse complète *
SIRET * setProfileData({...profileData, siret: e.target.value})} disabled={isCollaborator} className="w-full border-2 border-slate-200 rounded-xl p-3 text-sm focus:border-emerald-500 outline-none transition disabled:bg-slate-50" />
{!isCollaborator && (
)}
{!isCollaborator && (
setProfileData({...profileData, monthlyReport: e.target.checked ? 1 : 0})} className="w-5 h-5 accent-emerald-600" />
Rapports d'activité
Recevoir un e-mail mensuel sur l'usage des panneaux.
)}
{!isCollaborator &&
Enregistrer les modifications }
{!isCollaborator && uiMode === 'professionnel' && (
Équipe de collaborateurs
Gérez les accès de votre équipe à vos chantiers.
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})
)}
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.
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
)}
);
// Initialisation des variables pour le résumé temps réel du prix
const pRental = data.prices?.rentalMo || 180;
const pPurchase = data.prices?.purchase || 850;
const pBoardFirst = data.prices?.boardFirst || 250;
const pBoardAdd = data.prices?.boardAdd || 150;
const pNoAds = data.prices?.noAds || 150;
const currentOfferType = managingChantier?.offerType || 'rental';
const currentPanels = managingChantier?.physicalPanels || 0;
const currentHasNoAds = managingChantier?.hasNoAds || false;
let boardCostHT = 0;
if (currentPanels > 0) {
boardCostHT = pBoardFirst + (currentPanels - 1) * pBoardAdd;
}
let upfrontHT = boardCostHT + (currentHasNoAds ? pNoAds : 0);
if (currentOfferType === 'rental') {
upfrontHT += pRental;
} else {
upfrontHT += pPurchase;
}
const discountPct = myClientData.discount || 0;
const priceMultiplier = Math.max(0, (100 - discountPct) / 100);
const platformHasTva = data.settings?.billing_has_tva !== '0';
const tvaMult = platformHasTva ? 1.20 : 1.00;
const finalUpfrontTTC = upfrontHT * priceMultiplier * tvaMult;
const finalMonthlyTTC = pRental * priceMultiplier * tvaMult;
return (
Mode avancé
handleUiModeChange(e.target.checked)} disabled={isSaving} />
) : null}
>
{activeTab === 'dashboard' && renderDashboard()}
{activeTab === 'panels' && renderPanels()}
{activeTab === 'livraisons' && renderLivraisons()}
{activeTab === 'messages' && renderMessages()}
{activeTab === 'billing' && renderBilling()}
{activeTab === 'account' && renderAccount()}
{/* Modale d'avertissement TVA */}
{showTvaWarning && (
setShowTvaWarning(false)}>
Déclaration sur l'honneur
En désactivant ce champ, vous attestez sur l'honneur relever d'un régime d'exonération de TVA (ex: franchise en base de TVA des micro-entreprises - art. 293 B du CGI).
Veuillez noter que la plateforme eco-panneau.fr est tenue de vous facturer la TVA française (20%) sur ses services. En tant que non-assujetti, vous ne pourrez pas la récupérer fiscalement.
Toute fausse déclaration engage votre responsabilité exclusive vis-à-vis de l'administration fiscale.
setShowTvaWarning(false)} className="flex-1 bg-slate-100 text-slate-700 py-3 rounded-xl font-bold hover:bg-slate-200 transition">Annuler
J'atteste et je désactive
)}
{/* Modale de gestion d'équipe */}
{modalStep === 'team_manage' && (
setModalStep('config_full')}>
Membres actuels
{data.team?.map((member, i) => (
handleDeleteTeamMember(member.id)} disabled={isSaving} className="p-2 text-red-500 hover:bg-red-100 rounded-lg transition" title="Supprimer ce collaborateur">
))}
{(!data.team || data.team.length === 0) &&
Aucun collaborateur dans votre équipe.
}
)}
{/* Modales d'actions */}
{isModalOpen && modalStep.startsWith('config') && (
{ if(e.target === e.currentTarget) document.getElementById('editor-cancel-btn')?.click(); }}>
e.stopPropagation()}>
{managingChantier.id ? 'Édition du chantier' : 'Nouveau chantier'} Assurez-vous de l'exactitude des informations légales.
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={() => handleSaveChantier(true)}
onPublish={
isCollaborator
? (managingChantier.status === 'Actif' ? () => {
const errs = validatePanel();
if (errs.length > 0) setValidationErrors(errs);
else handleSaveChantier(false);
} : undefined)
: () => {
const errs = validatePanel();
if (errs.length > 0) {
setValidationErrors(errs);
} else {
setValidationErrors([]);
if (managingChantier.status === 'Brouillon') {
setModalStep('select_offer');
} else {
handleSaveChantier(false);
}
}
}
}
onPreview={() => setPreviewChantier(managingChantier)}
onEditEntity={(loc, data) => setEditingEntity({location: loc, data})}
draggedItem={draggedItem}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDrop={handleDrop}
deleteEntity={deleteEntity}
isSaving={isSaving}
uiMode={uiMode}
settings={data.settings}
validationErrors={validationErrors}
/>
{editingEntity &&
}
)}
{isModalOpen && modalStep === 'select_offer' && (
Choix de l'offre Sélectionnez le type d'abonnement et les options matérielles.
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">
{/* Option Abonnement Mensuel */}
setManagingChantier({...managingChantier, offerType: 'rental'})}
className={`p-6 rounded-2xl border-2 cursor-pointer transition flex flex-col justify-between ${currentOfferType === 'rental' ? 'border-emerald-500 bg-emerald-50 shadow-md' : 'border-slate-200 hover:border-emerald-200'}`}
>
{pRental} € HT / mois
Paiement mensuel automatisé. Résiliable à tout moment, en un clic, à la fin de votre chantier.
{/* Option Achat Définitif */}
setManagingChantier({...managingChantier, offerType: 'purchase'})}
className={`p-6 rounded-2xl border-2 cursor-pointer transition flex flex-col justify-between ${currentOfferType === 'purchase' ? 'border-emerald-500 bg-emerald-50 shadow-md' : 'border-slate-200 hover:border-emerald-200'}`}
>
{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.
setManagingChantier({...managingChantier, physicalPanels: Math.max(0, 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}
setManagingChantier({...managingChantier, 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">+
{/* Résumé en temps réel */}
Résumé de votre commande
{currentOfferType === 'rental' ? 'Abonnement mensuel (1er mois inclus)' : 'Achat définitif'}
{(currentOfferType === 'rental' ? pRental : pPurchase).toFixed(2)} € HT
{currentPanels > 0 && (
Panneaux physiques ({currentPanels}x)
{boardCostHT.toFixed(2)} € HT
)}
{currentHasNoAds && (
Option marque blanche
{pNoAds.toFixed(2)} € HT
)}
{discountPct > 0 && (
Remise client ({discountPct}%)
-{(upfrontHT * (discountPct / 100)).toFixed(2)} € HT
)}
Total à payer aujourd'hui
{platformHasTva ? 'TVA 20% incluse' : 'TVA non applicable'}
{finalUpfrontTTC.toFixed(2)} €
{currentOfferType === 'rental' &&
Puis {finalMonthlyTTC.toFixed(2)} € TTC / mois
}
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
{isSaving ? : } Confirmer l'offre et payer
)}
{isModalOpen && modalStep === 'payment' && paymentData && (
setIsModalOpen(false)} preventClose={isSaving}>
setIsModalOpen(false)} />
)}
{isModalOpen && modalStep === 'share' && (
setIsModalOpen(false)}>
)}
{isModalOpen && modalStep === 'legal_vault' && (
setIsModalOpen(false)}>
Preuves et constats d'huissier
Ces documents sont chiffrés et inaccessibles au public. Ils seront inclus dans l'archive D.O.E lors de la clôture du chantier.
{managingChantier.privateDocs?.map((doc, idx) => (
window.open(`?api=file/download_private&type=${doc.type}&id=${doc.id}`, '_blank')} title="Télécharger le document" className="p-2 text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition">
{
const newDocs = [...managingChantier.privateDocs]; newDocs.splice(idx, 1);
setManagingChantier({...managingChantier, privateDocs: newDocs}); handleSaveChantier(true);
}} title="Supprimer ce document privé" className="p-2 text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition">
))}
{(!managingChantier.privateDocs || managingChantier.privateDocs.length === 0) &&
Aucun document privé stocké.
}
Ajouter une preuve (PDF/image)
{
const file = e.target.files[0]; if(!file) return;
if (file.size > 5 * 1024 * 1024) return showToast("Fichier trop lourd (max 5 Mo).", "error");
setIsSaving(true);
try {
const type = file.type.includes('pdf') ? 'pdf' : 'image';
const id = await window.uploadFile(file, type, null, true);
const newDocs = [...(managingChantier.privateDocs || []), { id, type, name: file.name, date: new Date().toLocaleDateString('fr-FR') }];
const updatedChantier = {...managingChantier, privateDocs: newDocs};
setManagingChantier(updatedChantier);
await fetch(window.ECO_CONFIG.apiBaseUrl + 'chantiers', { method: 'POST', body: JSON.stringify({ id: updatedChantier.id, status: updatedChantier.status, offerType: updatedChantier.offerType, currentRate: updatedChantier.currentRate, physicalPanels: updatedChantier.physicalPanels, details: updatedChantier }) });
refreshData();
} catch(err) { showToast(err.message, "error"); }
setIsSaving(false); e.target.value = null;
}} />
)}
{previewChantier && setPreviewChantier(null)} showToast={showToast} refreshData={refreshData} />}
);
};
/* EOF ===== [_clients.jsx] =============== */