Initial commit
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user