Files
crowdlending-app/frontend/src/components/Layout.jsx
T
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

342 lines
13 KiB
React

import { useCallback, useEffect, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import Logo from './Logo.jsx';
import UserMenu from './UserMenu.jsx';
/* ── Icônes nav ─────────────────────────────────────────────── */
const ICONS_BASE = '/api/icons-files/';
const I = ({ children }) => (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
{children}
</svg>
);
const IconDashboard = () => <I><rect x="1" y="1" width="7" height="7" rx="1.5"/><rect x="10" y="1" width="7" height="7" rx="1.5"/><rect x="1" y="10" width="7" height="7" rx="1.5"/><rect x="10" y="10" width="7" height="7" rx="1.5"/></I>;
const IconDeposits = () => <I><line x1="6" y1="14" x2="6" y2="4"/><polyline points="3,7 6,4 9,7"/><line x1="12" y1="4" x2="12" y2="14"/><polyline points="9,11 12,14 15,11"/></I>;
const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyline points="11,3 15,3 15,7"/></I>;
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
function NavIcon({ libFilename, Fallback }) {
if (libFilename) {
return (
<img
src={`${ICONS_BASE}${libFilename}`}
width="24" height="24"
className="nav-lib-icon"
aria-hidden="true"
alt=""
/>
);
}
return <Fallback />;
}
/* Bouton « réduire » (visible dans la sidebar étendue) */
const IconPanelCollapse = () => (
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
<line x1="7" y1=".75" x2="7" y2="17.25"/>
<path d="M13 5.5 L10 9 L13 12.5"/>
</svg>
);
/* Bouton « étendre » (apparaît au hover du logo en mode réduit) */
const IconPanelExpand = () => (
<svg width="18" height="18" viewBox="0 0 20 18" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<rect x=".75" y=".75" width="18.5" height="16.5" rx="2.5"/>
<line x1="7" y1=".75" x2="7" y2="17.25"/>
<path d="M3 5.5 L6 9 L3 12.5"/>
</svg>
);
/* ── Recherche rapide de projet ─────────────────────────────── */
function ProjectSearch() {
const navigate = useNavigate();
const { activeId, activeView } = useInvestisseur();
const [query, setQuery] = useState('');
const [allInv, setAllInv] = useState([]);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const inputRef = useRef(null);
const wrapRef = useRef(null);
/* Chargement (ou rechargement) des investissements */
const loadInv = useCallback(async () => {
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const rows = await api.get('/investissements', scopeParams);
setAllInv(rows);
} catch {}
}, [activeView]);
useEffect(() => { loadInv(); }, [loadInv, activeId]);
/* Raccourci clavier global Ctrl+K / Cmd+K */
useEffect(() => {
const h = e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
};
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, []);
/* Fermeture au clic extérieur */
useEffect(() => {
const h = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, []);
/* Résultats filtrés */
const results = (() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return allInv
.filter(r => r.nom_projet?.toLowerCase().includes(q) || r.plateforme_nom?.toLowerCase().includes(q))
.slice(0, 8);
})();
/* Synchronise l'ouverture du dropdown */
useEffect(() => {
setOpen(results.length > 0 && query.trim().length > 0);
setActiveIdx(-1);
}, [results.length, query]); /* eslint-disable-line */
const goTo = (inv) => {
setQuery(''); setOpen(false);
navigate(`/investissements/${inv.id}`);
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, -1)); }
if (e.key === 'Enter') { e.preventDefault(); if (activeIdx >= 0) goTo(results[activeIdx]); else if (results.length === 1) goTo(results[0]); }
if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); }
};
const STATUT_LABELS = {
en_cours: 'en cours',
rembourse: 'remboursé',
en_retard: 'en retard',
procedure: 'procédure',
cloture: 'clôturé',
};
const statutColor = (s) => {
if (s === 'en_cours') return 'var(--b-en_cours-fg)';
if (s === 'rembourse') return 'var(--b-rembourse-fg)';
if (s === 'en_retard') return 'var(--b-en_retard-fg)';
if (s === 'procedure') return 'var(--b-procedure-fg)';
return 'var(--text-muted)';
};
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<div className="project-search-wrap">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
style={{ opacity: 0.7, flexShrink: 0 }}>
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref={inputRef}
className="project-search-input"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Rechercher un projet…"
autoComplete="off"
spellCheck="false"
/>
{query ? (
<button className="project-search-clear"
onMouseDown={e => { e.preventDefault(); setQuery(''); setOpen(false); inputRef.current?.focus(); }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
) : (
<kbd className="project-search-kbd">K</kbd>
)}
</div>
{open && (
<div className="project-search-dropdown">
{results.map((inv, i) => (
<div key={inv.id}
className={`project-search-item${activeIdx === i ? ' active' : ''}`}
onMouseDown={() => goTo(inv)}
onMouseEnter={() => setActiveIdx(i)}
>
<div className="project-search-item-name">{inv.nom_projet}</div>
<div className="project-search-item-meta">
<span>{inv.plateforme_nom}</span>
<span>·</span>
<span style={{ color: statutColor(inv.statut) }}>{STATUT_LABELS[inv.statut] ?? inv.statut?.replace('_', ' ')}</span>
{inv.montant_investi != null && (
<>
<span>·</span>
<span>{inv.montant_investi.toLocaleString('fr-FR')} </span>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
/* ── Layout ─────────────────────────────────────────────────── */
function IconPlusCircle() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
);
}
export default function Layout() {
const { sidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({});
useEffect(() => {
api.get('/icons').then(rows => {
const m = {};
rows.forEach(r => { m[r.name] = r.filename; });
setNavIcons(m);
}).catch(() => {});
}, []);
return (
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
<aside className="sidebar">
{/* ── Header ─────────────────────────────────────────── */}
<div className="sidebar-brand">
<div className="sidebar-brand-logo">
<Logo size={34} />
</div>
<span className="sidebar-brand-text">Crowdlending</span>
{/* Réduire (visible seulement en mode étendu) */}
<button
className="sidebar-panel-btn"
onClick={toggleSidebar}
title="Réduire le menu"
aria-label="Réduire le menu"
>
<IconPanelCollapse />
</button>
{/* Étendre — overlay au hover du logo en mode réduit */}
<button
className="sidebar-expand-overlay"
onClick={toggleSidebar}
title="Ouvrir le menu"
aria-label="Ouvrir le menu"
>
<IconPanelExpand />
</button>
</div>
{/* ── Navigation ─────────────────────────────────────── */}
<nav className="sidebar-nav">
<NavLink to="/" end title="Tableau de bord">
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
</NavLink>
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
<NavLink to="/depots-retraits" title="Dépôts / Retraits">
<NavIcon libFilename={navIcons['depots-retraits']} Fallback={IconDeposits} /><span className="nav-label">Dépôts / Retraits</span>
</NavLink>
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
</nav>
{/* ── Pied : menu utilisateur ─────────────────────────── */}
<UserMenu />
</aside>
<main className="main">
<div className="topbar topbar-global">
<ProjectSearch />
<div className="topbar-right">
<button
className="btn-add-invest"
onClick={() => navigate('/investissements?new=1')}
>
<IconPlusCircle />
Ajout Investissement
</button>
<button
className="btn-add-invest"
onClick={() => navigate('/remboursements?new=1')}
>
<IconPlusCircle />
Nouveau remboursement
</button>
<button
className="btn-add-invest"
onClick={() => navigate('/depots-retraits?new=1')}
>
<IconPlusCircle />
Nouveau dépôt/retrait
</button>
<div className="display-toggle" role="group" aria-label="Mode d'affichage">
<button
className={`display-toggle-btn${displayMode === 'brut' ? ' active' : ''}`}
onClick={() => setDisplayMode('brut')}
aria-pressed={displayMode === 'brut'}
>
Brut
</button>
<button
className={`display-toggle-btn${displayMode === 'net' ? ' active' : ''}`}
onClick={() => setDisplayMode('net')}
aria-pressed={displayMode === 'net'}
>
Net
</button>
</div>
</div>
</div>
<Outlet />
</main>
</div>
);
}