Initial commit
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
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 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();
|
||||
|
||||
let mainLeft, mainBottom, mainWidth;
|
||||
if (sidebarCollapsed) {
|
||||
mainLeft = r.right + 8;
|
||||
mainBottom = window.innerHeight - r.bottom;
|
||||
mainWidth = POPUP_W;
|
||||
} else {
|
||||
mainLeft = r.left;
|
||||
mainBottom = window.innerHeight - 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 */
|
||||
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, 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'); };
|
||||
|
||||
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('/aide')}>
|
||||
<IconAide /> Aide
|
||||
</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('/compte?section=famille')}
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user