342 lines
13 KiB
React
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>
|
|
);
|
|
}
|