Message lorsqu'aucun enregistrement présent
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# MEMORY.md — Crowdlending Tracker
|
# MEMORY.md — Crowdlending Tracker
|
||||||
*Dernière mise à jour: 2026-05-09 (session 6)*
|
*Dernière mise à jour: 2026-06-15 (session 7)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -382,3 +382,46 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
|||||||
7. **Modal + dropdown** : ne jamais utiliser `position: absolute` pour un dropdown dans une modale — utiliser `position: fixed` + `getBoundingClientRect()` (cf. `CategorySelect.jsx`).
|
7. **Modal + dropdown** : ne jamais utiliser `position: absolute` pour un dropdown dans une modale — utiliser `position: fixed` + `getBoundingClientRect()` (cf. `CategorySelect.jsx`).
|
||||||
8. **Suppression dans modale** : ne pas chaîner `.then(() => closeModal())` sur une fonction qui appelle `confirm()` — inliner la logique avec early return si `!confirm(...)` pour éviter la fermeture sur "Annuler".
|
8. **Suppression dans modale** : ne pas chaîner `.then(() => closeModal())` sur une fonction qui appelle `confirm()` — inliner la logique avec early return si `!confirm(...)` pour éviter la fermeture sur "Annuler".
|
||||||
9. **Affichage investisseur** : toujours `memberLabel(inv)` — jamais `inv.prenom + ' ' + inv.nom` car `nom` contient déjà le nom complet.
|
9. **Affichage investisseur** : toujours `memberLabel(inv)` — jamais `inv.prenom + ' ' + inv.nom` car `nom` contient déjà le nom complet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 7 — Auth, sécurité, section Admin Général (2026-06-15)
|
||||||
|
|
||||||
|
### Système d'audit logs
|
||||||
|
- Table `audit_logs` (id, actor_id, target_user_id, action, category, details JSON, ip_address, user_agent, created_at)
|
||||||
|
- Util `backend/src/utils/audit.js` : `audit(req, { action, category, actorId, targetUserId, details })` — silencieux, ne throw jamais
|
||||||
|
- Route `GET /api/admin/audit-logs` (+ `/categories`) avec filtres page/limit/category/search/dateFrom/dateTo/userId, purge auto 30 jours
|
||||||
|
- Instrumenté dans : `auth.js` (register, login_failed, login_success, 2fa), `admin.js` (user_created, status_changed, role_changed, user_deleted), `invitations.js` (invitation_sent, invitation_accepted)
|
||||||
|
- Frontend : `AuditLogsSection.jsx` — badges catégories cliquables, recherche 350ms debounce, plage dates, pagination 50/page, `DetailTooltip` JSON au survol
|
||||||
|
|
||||||
|
### Bug focus-loss sur InvitationRegister
|
||||||
|
- **Cause** : composant `Wrap` défini à l'intérieur du corps de `InvitationRegister()` → new component type à chaque render → React démonte/remonte tous les enfants → perte de focus à chaque frappe
|
||||||
|
- **Fix** : déplacer `Wrap` en dehors de la fonction, passer `appInfo` en prop
|
||||||
|
- **Règle** : ne jamais définir un composant React à l'intérieur d'un autre composant
|
||||||
|
|
||||||
|
### Indicateur de complexité mot de passe (PasswordStrength.jsx)
|
||||||
|
- Composant `frontend/src/components/PasswordStrength.jsx`
|
||||||
|
- Props : `password: string`, `minLength: number = 8`
|
||||||
|
- 5 règles : len (≥ minLength), upper, lower, digit, special
|
||||||
|
- 6 niveaux visuels : Très faible → Très fort (couleurs fixes)
|
||||||
|
- Règle `len` dynamique via `buildRules(minLength)` appelé dans le composant
|
||||||
|
- Déployé sur 5 écrans : Register, ResetPassword, InvitationRegister, MonCompte (SecurityForm), UsersSection (CreateUserModal)
|
||||||
|
|
||||||
|
### Section Admin "Général" — paramètres globaux
|
||||||
|
- Route backend `GET/PATCH /api/admin/general` → `backend/src/routes/general.js`
|
||||||
|
- Colonnes DB ajoutées sur `smtp_config` : `allow_registration INTEGER DEFAULT 1`, `min_password_length INTEGER DEFAULT 8`
|
||||||
|
- Route publique `/api/app-info` enrichie : expose `allowRegistration` et `minPasswordLength` (sans auth)
|
||||||
|
- `SmtpSection.jsx` : champs appName/appUrl **retirés** (déplacés dans Général)
|
||||||
|
- `GeneralSection.jsx` : 3 blocs — Identité (appName, appUrl), Accès (toggle auto-inscription), Sécurité (longueur min MDP)
|
||||||
|
- `Admin.jsx` : section "Général" ajoutée en tête du groupe "Administration de la plateforme"
|
||||||
|
|
||||||
|
### Contrôle auto-inscription
|
||||||
|
- `App.jsx` : route `/register` → `<Navigate to="/login" replace />` si `allowRegistration=false`
|
||||||
|
- `Login.jsx` : lien "Créer un compte" conditionnel sur `appInfo.allowRegistration !== false`
|
||||||
|
- Fetch `/api/app-info` au montage dans App.jsx, état initial `allowRegistration: true` (jamais bloquant par défaut)
|
||||||
|
|
||||||
|
### minPasswordLength — propagation
|
||||||
|
- Chaque écran password fetch `/api/app-info` au montage et extrait `minPasswordLength`
|
||||||
|
- `useState(8)` comme valeur initiale sur tous les écrans → jamais bloquant si l'API est lente
|
||||||
|
- Register/ResetPassword/InvitationRegister : via `appInfo.minPasswordLength || 8` (appInfo déjà fetché)
|
||||||
|
- MonCompte (SecurityForm) et UsersSection (CreateUserModal) : state local `minPasswordLength` + useEffect fetch dédié
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État vide — affiché quand aucune plateforme n'est configurée.
|
||||||
|
* Props :
|
||||||
|
* icon — emoji ou caractère affiché en grand (défaut : '🏦')
|
||||||
|
* title — titre du message (défaut : 'Aucune plateforme configurée')
|
||||||
|
* message — texte explicatif (défaut : message générique plateformes)
|
||||||
|
* cta — libellé du bouton (défaut : 'Configurer mes plateformes')
|
||||||
|
* to — route cible du bouton (défaut : '/settings?section=plateformes')
|
||||||
|
*/
|
||||||
|
export default function EmptyState({
|
||||||
|
icon = '🏦',
|
||||||
|
title = 'Aucune plateforme configurée',
|
||||||
|
message = "Vous n'avez pas encore configuré de plateforme de crowdfunding sur votre compte. Associez au moins une plateforme pour commencer à suivre vos investissements.",
|
||||||
|
cta = 'Configurer mes plateformes',
|
||||||
|
to = '/settings?section=plateformes',
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '72px 24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
gap: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 56, lineHeight: 1 }}>{icon}</div>
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '1.35rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p style={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: 480,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontSize: '0.97rem',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(to)}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: '10px 24px',
|
||||||
|
background: 'var(--primary)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cta}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
|
|||||||
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
|
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||||
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
||||||
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
|
|
||||||
/* ── Sélecteur d'année — doit être enfant de InteretsChartProvider ── */
|
/* ── Sélecteur d'année — doit être enfant de InteretsChartProvider ── */
|
||||||
function YearSelectorKpi() {
|
function YearSelectorKpi() {
|
||||||
@@ -422,6 +423,7 @@ export default function Dashboard() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [capitalMensuelData, setCapitalMensuelData] = useState([]);
|
const [capitalMensuelData, setCapitalMensuelData] = useState([]);
|
||||||
const [plateformes, setPlateformes] = useState([]);
|
const [plateformes, setPlateformes] = useState([]);
|
||||||
|
const [platLoaded, setPlatLoaded] = useState(false);
|
||||||
|
|
||||||
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
||||||
const _now = new Date();
|
const _now = new Date();
|
||||||
@@ -461,7 +463,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/pfu').then(setPfuRates).catch(() => {});
|
api.get('/pfu').then(setPfuRates).catch(() => {});
|
||||||
api.get('/plateformes').then(setPlateformes).catch(() => {});
|
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => { setPlatLoaded(true); });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -475,6 +477,12 @@ export default function Dashboard() {
|
|||||||
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
|
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
|
||||||
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
|
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
|
||||||
if (loading || !data) return <div className="card text-muted">Chargement…</div>;
|
if (loading || !data) return <div className="card text-muted">Chargement…</div>;
|
||||||
|
if (platLoaded && plateformes.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2>Tableau de bord</h2></div>
|
||||||
|
<EmptyState />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
|
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
|
||||||
const viewTitle = activeView === 'all'
|
const viewTitle = activeView === 'all'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { usePagination } from '../hooks/usePagination.js';
|
import { usePagination } from '../hooks/usePagination.js';
|
||||||
import Pagination from '../components/Pagination.jsx';
|
import Pagination from '../components/Pagination.jsx';
|
||||||
import PageIcon from '../components/PageIcon.jsx';
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
@@ -882,6 +883,12 @@ export default function DepotsRetraits() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!loading && plats.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2>Dépôts / Retraits</h2></div>
|
||||||
|
<EmptyState />
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import DistributionChart from '../components/DistributionChart.jsx';
|
|||||||
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
|
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
|
||||||
import { fmtEUR, fmtPct, fmtDate, fmtStatut, today } from '../utils/format.js';
|
import { fmtEUR, fmtPct, fmtDate, fmtStatut, today } from '../utils/format.js';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
|
|
||||||
/* ── Constantes ──────────────────────────────────────────────── */
|
/* ── Constantes ──────────────────────────────────────────────── */
|
||||||
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||||
@@ -742,6 +743,12 @@ export default function Investissements() {
|
|||||||
const clearFilter = () => setFilter({ statut: '', plateforme_id: '', categorie_inv_id: '', secteur_inv_id: '', year: '', month: '' });
|
const clearFilter = () => setFilter({ statut: '', plateforme_id: '', categorie_inv_id: '', secteur_inv_id: '', year: '', month: '' });
|
||||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||||
|
|
||||||
|
if (!loading && plats.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2>Investissements</h2></div>
|
||||||
|
<EmptyState />
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from '../api.js';
|
|||||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
import { useUi } from '../context/UiContext.jsx';
|
import { useUi } from '../context/UiContext.jsx';
|
||||||
import PageIcon from '../components/PageIcon.jsx';
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
import InvChart from '../components/InvChart.jsx';
|
import InvChart from '../components/InvChart.jsx';
|
||||||
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
||||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
|
import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
|
||||||
@@ -567,9 +568,7 @@ export default function Plateformes() {
|
|||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<h2><PageIcon name="plateforme" />Plateformes</h2>
|
<h2><PageIcon name="plateforme" />Plateformes</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
<EmptyState />
|
||||||
Aucun investissement trouvé.
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { usePagination } from '../hooks/usePagination.js';
|
import { usePagination } from '../hooks/usePagination.js';
|
||||||
import Pagination from '../components/Pagination.jsx';
|
import Pagination from '../components/Pagination.jsx';
|
||||||
import PageIcon from '../components/PageIcon.jsx';
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
@@ -177,6 +178,7 @@ export default function Remboursements() {
|
|||||||
const [corrections, setCorrections] = useState([]);
|
const [corrections, setCorrections] = useState([]);
|
||||||
const [investissements, setInvestissements] = useState([]);
|
const [investissements, setInvestissements] = useState([]);
|
||||||
const [plateformes, setPlateformes] = useState([]);
|
const [plateformes, setPlateformes] = useState([]);
|
||||||
|
const [platLoaded, setPlatLoaded] = useState(false);
|
||||||
const [pfuRates, setPfuRates] = useState([]);
|
const [pfuRates, setPfuRates] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@ export default function Remboursements() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/pfu').then(setPfuRates).catch(() => {});
|
api.get('/pfu').then(setPfuRates).catch(() => {});
|
||||||
api.get('/plateformes').then(setPlateformes).catch(() => {});
|
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => { setPlatLoaded(true); });
|
||||||
api.get('/icons').then(rows => { const m = {}; rows.forEach(r => { m[r.name] = r.filename; }); setLibIcons(m); }).catch(() => {});
|
api.get('/icons').then(rows => { const m = {}; rows.forEach(r => { m[r.name] = r.filename; }); setLibIcons(m); }).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -760,6 +762,12 @@ export default function Remboursements() {
|
|||||||
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
||||||
|
|
||||||
/* ── Rendu ── */
|
/* ── Rendu ── */
|
||||||
|
if (platLoaded && !loading && plateformes.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2>Remboursements</h2></div>
|
||||||
|
<EmptyState />
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import PageIcon from '../components/PageIcon.jsx';
|
import PageIcon from '../components/PageIcon.jsx';
|
||||||
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
import Pagination from '../components/Pagination.jsx';
|
import Pagination from '../components/Pagination.jsx';
|
||||||
import { usePagination } from '../hooks/usePagination.js';
|
import { usePagination } from '../hooks/usePagination.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
@@ -249,6 +250,16 @@ export default function TaxReport() {
|
|||||||
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
|
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!loading && data && availableYears.length === 0) return (
|
||||||
|
<>
|
||||||
|
<div className="topbar"><h2>Fiscalité</h2></div>
|
||||||
|
<EmptyState
|
||||||
|
icon="📋"
|
||||||
|
title="Aucune donnée fiscale disponible"
|
||||||
|
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
|
|||||||
Reference in New Issue
Block a user