391 lines
17 KiB
React
391 lines
17 KiB
React
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../context/AuthContext.jsx';
|
|
import { useUi } from '../context/UiContext.jsx';
|
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
|
import { memberInitials, memberLabel } from '../utils/format.js';
|
|
|
|
/* ── Icons ───────────────────────────────────────────────────── */
|
|
function IconUser() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
|
|
}
|
|
function IconLogout() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>;
|
|
}
|
|
function IconAdmin() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="9" cy="6" r="3"/><path d="M2 16c0-3.3 3.1-6 7-6"/><path d="M14 13l-1.5 1.5L14 16"/><circle cx="15.5" cy="14.5" r="2.5"/></svg>;
|
|
}
|
|
function IconSettings() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><circle cx="8" cy="6" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="12" x2="20" y2="12"/><circle cx="16" cy="12" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="10" cy="18" r="2" fill="var(--user-menu-bg, #1e2d4a)"/></svg>;
|
|
}
|
|
function IconAide() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
|
|
}
|
|
function IconComm() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
|
|
}
|
|
function IconRefresh() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>;
|
|
}
|
|
function IconChevronRight() {
|
|
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
|
|
}
|
|
function IconChevronUp({ open }) {
|
|
return (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
style={{ transition: 'transform .2s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)', flexShrink: 0 }}
|
|
aria-hidden="true">
|
|
<path d="M18 15l-6-6-6 6"/>
|
|
</svg>
|
|
);
|
|
}
|
|
function IconCheck() {
|
|
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>;
|
|
}
|
|
function IconTeam() {
|
|
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>;
|
|
}
|
|
|
|
/* ── Avatars ─────────────────────────────────────────────────── */
|
|
function UserAvatar({ user, size = 36 }) {
|
|
const initials = user?.display_name
|
|
? user.display_name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
|
: (user?.email || '?')[0].toUpperCase();
|
|
return (
|
|
<div style={{
|
|
width: size, height: size, borderRadius: '50%',
|
|
background: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)',
|
|
border: '2px solid rgba(74,222,128,.5)',
|
|
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
|
|
letterSpacing: '.02em', userSelect: 'none',
|
|
}}>
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemberAvatar({ member, size = 28 }) {
|
|
const isEntreprise = member?.type === 'entreprise';
|
|
const bg = isEntreprise
|
|
? 'linear-gradient(135deg, #3730a3, #4338ca)'
|
|
: 'linear-gradient(135deg, #1e3a8a, #1e40af)';
|
|
return (
|
|
<div style={{
|
|
width: size, height: size, borderRadius: '50%',
|
|
background: bg, color: '#fff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
|
|
letterSpacing: '.02em', userSelect: 'none',
|
|
}}>
|
|
{memberInitials(member)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamBadge({ size = 28 }) {
|
|
return (
|
|
<div className="team-badge" style={{
|
|
width: size, height: size, borderRadius: '50%',
|
|
background: '#1e3a8a',
|
|
border: '1.5px solid rgba(255,255,255,.25)',
|
|
color: '#fff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}>
|
|
<IconTeam />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Composant principal ─────────────────────────────────────── */
|
|
export default function UserMenu() {
|
|
const { user, logout, isAdmin } = useAuth();
|
|
const { sidebarCollapsed } = useUi();
|
|
const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur();
|
|
const navigate = useNavigate();
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [subOpen, setSubOpen] = useState(false);
|
|
const [popupStyle, setPopupStyle] = useState({});
|
|
const [subStyle, setSubStyle] = useState({});
|
|
|
|
const triggerRef = useRef(null);
|
|
const popupRef = useRef(null);
|
|
const subRef = useRef(null);
|
|
const closeTimer = useRef(null);
|
|
|
|
const famille = investisseurs.filter(i => i.type !== 'entreprise');
|
|
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
|
|
|
|
const POPUP_W = 250;
|
|
const SUB_W = 230;
|
|
|
|
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
|
|
const computePosition = useCallback(() => {
|
|
if (!triggerRef.current) return;
|
|
const r = triggerRef.current.getBoundingClientRect();
|
|
// visualViewport est fiable sur Safari mobile (window.innerHeight ne l'est pas)
|
|
const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
|
|
|
let mainLeft, mainBottom, mainWidth;
|
|
if (sidebarCollapsed) {
|
|
mainLeft = r.right + 8;
|
|
mainBottom = vh - r.bottom;
|
|
mainWidth = POPUP_W;
|
|
} else {
|
|
mainLeft = r.left;
|
|
mainBottom = vh - r.top + 6;
|
|
mainWidth = r.width;
|
|
}
|
|
|
|
setPopupStyle({
|
|
position: 'fixed', left: mainLeft, bottom: mainBottom,
|
|
width: mainWidth, top: 'auto', right: 'auto',
|
|
});
|
|
setSubStyle({
|
|
position: 'fixed',
|
|
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
|
|
bottom: mainBottom,
|
|
width: SUB_W, top: 'auto', right: 'auto',
|
|
});
|
|
}, [sidebarCollapsed]);
|
|
|
|
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
|
|
const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []);
|
|
|
|
const clearClose = () => clearTimeout(closeTimer.current);
|
|
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
|
|
|
|
/* Fermeture clic extérieur */
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e) => {
|
|
const inTrigger = triggerRef.current?.contains(e.target);
|
|
const inPopup = popupRef.current?.contains(e.target);
|
|
const inSub = subRef.current?.contains(e.target);
|
|
if (!inTrigger && !inPopup && !inSub) closeMenu();
|
|
};
|
|
document.addEventListener('mousedown', onDown);
|
|
return () => document.removeEventListener('mousedown', onDown);
|
|
}, [open, closeMenu]);
|
|
|
|
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) */
|
|
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const vp = window.visualViewport;
|
|
if (!vp) return;
|
|
vp.addEventListener('resize', computePosition);
|
|
vp.addEventListener('scroll', computePosition);
|
|
return () => {
|
|
vp.removeEventListener('resize', computePosition);
|
|
vp.removeEventListener('scroll', computePosition);
|
|
};
|
|
}, [open, computePosition]);
|
|
|
|
/* Handlers ouverture */
|
|
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
|
|
const onWrapLeave = () => { if (sidebarCollapsed) scheduleClose(); };
|
|
const onTriggerClick = () => { if (!sidebarCollapsed) { open ? closeMenu() : openMenu(); } };
|
|
|
|
const go = (path) => { closeMenu(); navigate(path); };
|
|
const handleLogout = () => { closeMenu(); logout(); navigate('/login'); };
|
|
|
|
// En mode standalone (webapp ajoutée à l'écran d'accueil iPad), il n'y a
|
|
// ni bouton reload ni pull-to-refresh natif. On force donc une vraie
|
|
// navigation réseau (pas juste reload()) avec un paramètre anti-cache,
|
|
// pour être certain de récupérer l'index.html et les données à jour.
|
|
const handleForceRefresh = () => {
|
|
closeMenu();
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('_r', Date.now().toString());
|
|
window.location.replace(url.toString());
|
|
};
|
|
|
|
const selectView = (v) => {
|
|
setActiveView(v);
|
|
closeMenu();
|
|
};
|
|
|
|
/* ── Libellés ────────────────────────────────────────────── */
|
|
const viewLabel = activeView === 'all'
|
|
? 'Famille et entreprises'
|
|
: (activeViewMember ? memberLabel(activeViewMember) : 'Famille et entreprises');
|
|
|
|
const TriggerBadge = activeView === 'all'
|
|
? <TeamBadge size={30} />
|
|
: <MemberAvatar member={activeViewMember} size={30} />;
|
|
|
|
return (
|
|
<div
|
|
className="user-menu-wrap"
|
|
onMouseEnter={onWrapEnter}
|
|
onMouseLeave={onWrapLeave}
|
|
>
|
|
{/* ── Main Popup (portail → échappe tout stacking context) ── */}
|
|
{open && createPortal(
|
|
<div
|
|
ref={popupRef}
|
|
id="user-menu-popup"
|
|
className="user-menu-popup"
|
|
style={popupStyle}
|
|
role="menu"
|
|
onMouseEnter={clearClose}
|
|
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
|
|
>
|
|
{/* En-tête compte utilisateur */}
|
|
<div className="user-menu-header">
|
|
<UserAvatar user={user} size={40} />
|
|
<div style={{ overflow: 'hidden', flex: 1 }}>
|
|
{user?.display_name && <div className="user-menu-name">{user.display_name}</div>}
|
|
<div className="user-menu-email">{user?.email}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="user-menu-sep" />
|
|
|
|
{/* Section Vue du profil */}
|
|
<div className="user-menu-section-header">Vue du profil</div>
|
|
<button
|
|
className={`user-menu-item user-menu-vue-row${subOpen ? ' active' : ''}`}
|
|
role="menuitem"
|
|
onClick={() => setSubOpen(s => !s)}
|
|
>
|
|
{activeView === 'all'
|
|
? <TeamBadge size={24} />
|
|
: <MemberAvatar member={activeViewMember} size={24} />
|
|
}
|
|
<span className="user-menu-vue-name">{viewLabel}</span>
|
|
<span style={{ color: '#4a6490', display: 'flex' }}><IconChevronRight /></span>
|
|
</button>
|
|
|
|
<div className="user-menu-sep" />
|
|
|
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/compte')}>
|
|
<IconUser /> Mon compte
|
|
</button>
|
|
{isAdmin && (
|
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/admin')}>
|
|
<IconAdmin /> Administration
|
|
</button>
|
|
)}
|
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
|
|
<IconSettings /> Paramètres
|
|
</button>
|
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/communication')}>
|
|
<IconComm /> Support & Notifications
|
|
</button>
|
|
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
|
|
<IconAide /> Aide
|
|
</button>
|
|
<button className="user-menu-item" role="menuitem" onClick={handleForceRefresh}>
|
|
<IconRefresh /> Recharger l'application
|
|
</button>
|
|
|
|
<div className="user-menu-sep" />
|
|
|
|
<button className="user-menu-item danger" role="menuitem" onClick={handleLogout}>
|
|
<IconLogout /> Se déconnecter
|
|
</button>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* ── Sub-panel Vue du profil (portail) ───────────────────── */}
|
|
{open && subOpen && createPortal(
|
|
<div
|
|
ref={subRef}
|
|
className="user-menu-subpanel"
|
|
style={subStyle}
|
|
onMouseEnter={clearClose}
|
|
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
|
|
>
|
|
<div className="user-menu-subpanel-title">Vue du profil</div>
|
|
|
|
{/* Famille et entreprises (vue agrégée) */}
|
|
<button
|
|
className={`user-menu-profile-item${activeView === 'all' ? ' selected' : ''}`}
|
|
onClick={() => selectView('all')}
|
|
>
|
|
<TeamBadge size={26} />
|
|
<span className="user-menu-profile-name">Famille et entreprises</span>
|
|
{activeView === 'all' && <IconCheck />}
|
|
</button>
|
|
|
|
{/* Membres famille */}
|
|
{famille.length > 0 && (
|
|
<>
|
|
<div className="user-menu-subpanel-section">
|
|
{famille.length > 1 ? 'Profils' : 'Profil'}
|
|
</div>
|
|
{famille.map(m => (
|
|
<button
|
|
key={m.id}
|
|
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
|
|
onClick={() => selectView(String(m.id))}
|
|
>
|
|
<MemberAvatar member={m} size={26} />
|
|
<span className="user-menu-profile-name">{memberLabel(m)}</span>
|
|
{String(activeView) === String(m.id) && <IconCheck />}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* Entreprises */}
|
|
{entreprises.length > 0 && (
|
|
<>
|
|
<div className="user-menu-subpanel-section">
|
|
{entreprises.length > 1 ? 'Entreprises' : 'Entreprise'}
|
|
</div>
|
|
{entreprises.map(m => (
|
|
<button
|
|
key={m.id}
|
|
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
|
|
onClick={() => selectView(String(m.id))}
|
|
>
|
|
<MemberAvatar member={m} size={26} />
|
|
<span className="user-menu-profile-name">{memberLabel(m)}</span>
|
|
{String(activeView) === String(m.id) && <IconCheck />}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<div className="user-menu-sep" style={{ margin: '6px 0' }} />
|
|
|
|
<button
|
|
className="user-menu-profile-manage"
|
|
onClick={() => go('/settings?section=membres')}
|
|
>
|
|
Gérer les profils
|
|
</button>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* ── Déclencheur (bas de sidebar) ─────────────────────── */}
|
|
<button
|
|
ref={triggerRef}
|
|
className="user-menu-trigger"
|
|
onClick={onTriggerClick}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
title={viewLabel}
|
|
>
|
|
{TriggerBadge}
|
|
{!sidebarCollapsed && (
|
|
<div className="user-menu-trigger-info">
|
|
<span className="user-menu-trigger-name">{viewLabel}</span>
|
|
</div>
|
|
)}
|
|
{!sidebarCollapsed && <IconChevronUp open={open} />}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|