This commit is contained in:
ocroguennec committed 2026-09-26 11:15:26 +02:00
1 parent 14cf4e5a19
commit 26197fc411
15 files changed
+1776 -29

No files matched your search

+139 -7
View File
@@ -9,7 +9,7 @@ import InvestissementPeFormModal from '../components/InvestissementPeFormModal.j
import FraisFormModal from '../components/FraisFormModal.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, fmtOctets, today } from '../utils/format.js';
import { fmtEUR, fmtDate, fmtOctets, today, MODE_DETENTION_LABELS } from '../utils/format.js';
import { emptyDealForm, AUTO_VAL_NOTE, AUTO_VAL_NOTE_LEGACY } from './InvestissementsPe.jsx';
/**
@@ -44,12 +44,6 @@ const STATUT_LABELS = {
perte_definitive: 'Perte définitive',
};
const MODE_DETENTION_LABELS = {
direct: 'Direct',
pea_pme: 'PEA / PEA-PME',
assurance_vie: 'Assurance-vie',
};
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
@@ -356,6 +350,63 @@ export default function InvestissementPeDetail() {
} catch (e) { setErr(e.message); }
};
/* ── Déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ──────────
* Même principe que InvestissementDetail.jsx côté crowdlending (openPerteModal/submitPerte/
* cancelPerteDeclaration) mais adapté : un deal PE n'a pas de statut intermédiaire de
* recouvrement (en_retard/procedure), donc l'action est proposée pour tout statut autre que
* 'perte_definitive' plutôt que réservée à un sous-ensemble (cf. POST /:id/perte-definitive
* côté backend, même garde-fou). Montant préfilli avec le capital pas encore restitué
* (montant investi/investissable − distributions déjà classées "capital"), mais reste éditable
* (perte partielle possible si une partie a déjà été récupérée).
*/
const [perteModalOpen, setPerteModalOpen] = useState(false);
const [perteForm, setPerteForm] = useState({ date_effet: today(), montant_perte: '', motif: '' });
const [perteErr, setPerteErr] = useState(null);
const [perteSaving, setPerteSaving] = useState(false);
const [confirmingPerteCancel, setConfirmingPerteCancel] = useState(false);
const openPerteModal = () => {
const capInvestissable = deal?.capital_investissable ?? deal?.montant_investi ?? 0;
const capNonRestitue = Math.max(0, capInvestissable - (deal?.capital_restitue_cumule || 0));
setPerteForm({ date_effet: today(), montant_perte: String(Math.round(capNonRestitue * 100) / 100), motif: '' });
setPerteErr(null);
setPerteModalOpen(true);
};
const closePerteModal = () => { setPerteModalOpen(false); setPerteErr(null); };
const submitPerte = async (e) => {
e?.preventDefault?.();
setPerteErr(null);
if (!perteForm.date_effet) { setPerteErr("La date d'effet est requise."); return; }
if (perteForm.montant_perte === '' || Number(perteForm.montant_perte) < 0) {
setPerteErr('Le montant de la perte doit être un nombre positif ou nul.');
return;
}
if (!perteForm.motif.trim()) { setPerteErr('Le motif est obligatoire.'); return; }
setPerteSaving(true);
try {
await api.post(`/investissements-pe/${id}/perte-definitive`, {
date_effet: perteForm.date_effet,
montant_perte: Number(perteForm.montant_perte),
motif: perteForm.motif.trim(),
});
setPerteModalOpen(false);
await load();
} catch (e) {
const fieldErrors = e.details?.fieldErrors;
const detail = fieldErrors ? Object.values(fieldErrors).flat().filter(Boolean).join(' ') : '';
setPerteErr(detail || e.message);
} finally { setPerteSaving(false); }
};
const cancelPerteDeclaration = async () => {
try {
await api.del(`/investissements-pe/${id}/perte-definitive`);
setConfirmingPerteCancel(false);
await load();
} catch (e) { setConfirmingPerteCancel(false); }
};
/* ── Valorisations ── */
const [valForm, setValForm] = useState(emptyValForm);
const [valErr, setValErr] = useState(null);
@@ -1010,6 +1061,72 @@ export default function InvestissementPeDetail() {
onCancel={() => setDocDeleteTarget(null)}
/>
{/* ── Modal déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ──
Même contenu que la modale équivalente côté crowdlending (InvestissementDetail.jsx),
adapté au vocabulaire PE ("deal" plutôt que "dossier", case 2TY via taxreport-pe). */}
<Modal
open={perteModalOpen}
title="Déclarer une perte définitive"
onClose={closePerteModal}
width={520}
busy={perteSaving}
footer={
<>
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
</button>
</>
}
>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
À utiliser uniquement lorsque le capital est réellement irrécouvrable (fonds liquidé
sans distribution, société sous-jacente radiée…) — pas une simple baisse de
valorisation latente. Une fois déclaré, ce deal sort du capital investi/en risque et
le montant est reporté dans votre synthèse fiscale PE (case 2TY), pour l'année de la
date d'effet ci-dessous.
</p>
<form onSubmit={submitPerte}>
<label>Date d'effet (constat de la perte)</label>
<input
type="date"
value={perteForm.date_effet}
onChange={e => setPerteForm(f => ({ ...f, date_effet: e.target.value }))}
required
/>
<label style={{ marginTop: 12, display: 'block' }}>
Montant de la perte
<span className="text-muted"> — capital pas encore restitué : {fmtEUR(Math.max(0, (deal?.capital_investissable ?? deal?.montant_investi ?? 0) - (deal?.capital_restitue_cumule || 0)))}</span>
</label>
<input
type="number" step="0.01" min="0" required
value={perteForm.montant_perte}
onChange={e => setPerteForm(f => ({ ...f, montant_perte: e.target.value }))}
/>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
Préfilli avec le capital pas encore restitué — ajustez si une partie a déjà été
récupérée (perte partielle).
</div>
<label style={{ marginTop: 12, display: 'block' }}>Motif</label>
<textarea
rows={3}
placeholder="Ex : liquidation du fonds, société sous-jacente radiée…"
value={perteForm.motif}
onChange={e => setPerteForm(f => ({ ...f, motif: e.target.value }))}
required
/>
{perteErr && <div className="form-error" style={{ marginTop: 10 }}>{perteErr}</div>}
</form>
</Modal>
<ConfirmModal
open={confirmingPerteCancel}
title="Annuler la déclaration de perte ?"
message="Le deal retrouve son statut précédent et le montant sort de votre synthèse fiscale PE."
confirmLabel="Annuler la déclaration"
onConfirm={cancelPerteDeclaration}
onCancel={() => setConfirmingPerteCancel(false)}
/>
<FraisFormModal
open={fraisModalOpen}
onClose={closeFraisModal}
@@ -1041,6 +1158,21 @@ export default function InvestissementPeDetail() {
items={[
{ label: 'Modifier', icon: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>, action: () => { setCardMenu(null); openEditDeal(); } },
{ label: 'Exporter', icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>, action: () => { setCardMenu(null); exportDeal(); } },
// Déclarer/annuler une perte définitive (20/09/26, chantier "Fiscalité PE") — même
// paire d'actions que le menu "⋮" crowdlending (InvestissementDetail.jsx).
deal && deal.statut !== 'perte_definitive' && {
label: 'Déclarer une perte définitive',
icon: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
danger: true,
separatorBefore: true,
action: () => { setCardMenu(null); openPerteModal(); },
},
deal && deal.statut === 'perte_definitive' && {
label: 'Annuler la déclaration de perte',
icon: <><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-9.36L1 10"/></>,
separatorBefore: true,
action: () => { setCardMenu(null); setConfirmingPerteCancel(true); },
},
{ label: 'Supprimer', danger: true, separatorBefore: true, icon: <><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></>, action: () => { setCardMenu(null); setDeleteConfirm(true); } },
]}
/>
+3 -2
View File
@@ -8,7 +8,7 @@ import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, memberLabel } from '../utils/format.js';
import { fmtEUR, fmtDate, memberLabel, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js';
import {
sortMouvements, computeSoldeIndicatifMap, signedMontant, MOUV_TYPE_LABELS,
ExportDropdown, mouvToCSV, mouvToXLS, mouvToJSON, timestampSuffix, dlBlob,
@@ -480,7 +480,8 @@ export default function PlateformesPe() {
<td>{fmtDate(r.date_remb)}</td>
<td>{deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.investisseur_nom || '—'}</td>}
<td className="text-muted">{r.distribution_nature === 'interet_non_deploye' ? 'Intérêt sur capital non déployé' : 'Restitution de capital'}</td>
{/* 20/09/26, chantier "Fiscalité PE" : ternaire remplacé par la constante partagée DISTRIBUTION_NATURE_LABELS (utils/format.js) pour inclure la nouvelle nature 'plus_value' sans dupliquer les libellés (cf. RemboursementsSimple.jsx). */}
<td className="text-muted">{DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || 'Restitution de capital'}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
</tr>
))}
+24 -9
View File
@@ -13,7 +13,7 @@ import Pagination from '../components/Pagination.jsx';
import InteretsChart from '../components/InteretsChart.jsx';
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import { fmtEUR, fmtDate, today, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js';
const TYPE_LABELS = {
bonus_plateforme: 'Bonus plateforme',
@@ -21,11 +21,9 @@ const TYPE_LABELS = {
interets_plateforme: 'Distribution',
};
// Nature de la distribution (18/09/26) — affichée en sous-texte sous le type dans le tableau.
const DISTRIBUTION_NATURE_LABELS = {
capital: 'Restitution de capital',
interet_non_deploye: 'Intérêt sur capital non déployé',
};
// Nature de la distribution (18/09/26 ; libellés extraits en constante partagée le 20/09/26,
// chantier "Fiscalité PE" — cf. utils/format.js, réutilisée aussi par PlateformesPe.jsx).
// Affichée en sous-texte sous le type dans le tableau.
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
@@ -395,13 +393,30 @@ export default function RemboursementsSimple() {
if (key === 'taxe_locale') {
next.interets_bruts = round2(Number(next.interets_bruts_avant_local || 0) - Number(value));
}
if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc'].includes(key)) {
// Recalcul PFU/PS (20/09/26, chantier "Fiscalité PE" — cf. taxreportPe.js) : désormais
// dépendant à la fois de la nature de la distribution ET du mode de détention du deal lié
// (investissements_pe.mode_detention), pas seulement de l'année comme avant. 'distribution_nature'
// et 'investissement_pe_id' redéclenchent donc aussi ce recalcul, contrairement à avant.
// - capital (restitution de capital déployé) : jamais imposable, quel que soit le mode.
// - direct : PFU complet (IR + PS), comme avant (comportement historique inchangé).
// - pea_pme : prélèvements sociaux seuls (IR nativement exonéré dans le plan).
// - assurance_vie : aucun taux automatique (dépend du contrat) — laissé à 0, saisie manuelle.
if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc', 'distribution_nature', 'investissement_pe_id'].includes(key)) {
const dateStr = key === 'date_remb' ? value : next.date_remb;
const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0));
const rates = getRatesForYearPe(dateStr, pfuRates);
if (rates) {
const dealSel = deals.find(d => String(d.id) === String(next.investissement_pe_id));
const modeDetention = dealSel?.mode_detention || 'direct';
if (next.distribution_nature === 'capital') {
next.prelev_sociaux = 0;
next.prelev_forfaitaire = 0;
} else if (modeDetention === 'assurance_vie') {
next.prelev_sociaux = 0;
next.prelev_forfaitaire = 0;
} else if (rates) {
next.prelev_sociaux = round2(base * rates.prelev_sociaux / 100);
next.prelev_forfaitaire = round2(base * rates.impot_revenu / 100);
next.prelev_forfaitaire = modeDetention === 'pea_pme' ? 0 : round2(base * rates.impot_revenu / 100);
}
}
setForm(next);
+451
View File
@@ -0,0 +1,451 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import { fmtEUR, fmtDate, MODE_DETENTION_LABELS } from '../utils/format.js';
import Cerfa2042Pe from '../components/Cerfa2042Pe.jsx';
import Cerfa2561Pe from '../components/Cerfa2561Pe.jsx';
/* ── YearSelector ─────────────────────────────────────────────
* Copié de TaxReport.jsx (même comportement — pas d'extraction en composant partagé pour
* l'instant afin de ne pas toucher au fichier crowdlending existant dans ce chantier ; la
* page Plateformes a son propre AnneeSelector, légèrement différent car il autorise un choix
* "Depuis le début" hors de propos ici, une année étant toujours requise pour la fiscalité). */
function YearSelector({ annee, setAnnee, availableYears }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 160 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
height: '100%', boxSizing: 'border-box',
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
borderRadius: 10,
padding: '12px 16px',
boxShadow: open
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
cursor: 'pointer',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
userSelect: 'none',
transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>Année</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{ color: '#fff', fontSize: '1.8rem', fontWeight: 700, lineHeight: 1.1, marginTop: 6 }}>
{annee}
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
minWidth: 160, overflow: 'hidden',
}}>
{availableYears.map(yr => {
const isActive = String(yr) === String(annee);
return (
<div
key={yr}
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
style={{
padding: '10px 16px', cursor: 'pointer',
background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent',
color: isActive ? 'var(--primary)' : 'var(--text)',
fontWeight: isActive ? 700 : 400,
fontSize: 'var(--fs-sm)',
}}
>
{yr}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Ligne d'information CERFA (repris du format InfoLigneCerfa de TaxReport.jsx) ── */
function InfoLigneCerfa({ code, titre, texte }) {
return (
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{
flexShrink: 0, minWidth: 62, textAlign: 'center',
fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-xs)',
color: 'var(--primary)', background: 'rgba(99,102,241,0.07)',
borderRadius: 6, padding: '4px 6px', letterSpacing: '.03em', whiteSpace: 'nowrap',
}}>{code}</div>
<div>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{titre}</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.5 }}>{texte}</div>
</div>
</div>
);
}
/* ── Schéma : quel formulaire alimente quel formulaire (PE, détention directe) ──
* Adapté de CerfaFlowDiagram (TaxReport.jsx) : contrairement au crowdlending, deux
* formulaires alimentent la 2042 en parallèle et ne sont JAMAIS fusionnés — le 2561
* (intérêt sur capital non déployé) et le 2074 (plus ou moins-value de cession). ── */
function CerfaFlowDiagramPe() {
const boxFill = 'var(--surface-2)';
const box = (x, y, w, h, code, label, highlight) => (
<g key={code}>
<rect x={x} y={y} width={w} height={h} rx={10}
fill={highlight ? 'rgba(99,102,241,0.08)' : boxFill}
stroke={highlight ? 'var(--primary)' : 'var(--border)'} strokeWidth={highlight ? 1.8 : 1.3} />
<text x={x + w / 2} y={y + h / 2 - 6} textAnchor="middle" fontFamily="monospace" fontWeight="800"
fontSize="14" fill="var(--primary)">{code}</text>
<text x={x + w / 2} y={y + h / 2 + 13} textAnchor="middle" fontSize="10" fill="var(--text-muted)">{label}</text>
</g>
);
const arrow = (x1, y1, x2, y2, key) => (
<line key={key} x1={x1} y1={y1} x2={x2} y2={y2}
stroke="var(--text-muted)" strokeWidth={1.6} markerEnd="url(#cerfaFlowArrowPe)" />
);
return (
<svg viewBox="0 0 480 190" style={{ width: '100%', maxWidth: 460, height: 'auto', display: 'block' }}>
<defs>
<marker id="cerfaFlowArrowPe" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="var(--text-muted)" />
</marker>
</defs>
{arrow(180, 35, 268, 78, 'a1')}
{arrow(180, 150, 268, 108, 'a2')}
{box(20, 10, 160, 50, '2561', 'IFU (intérêt)')}
{box(20, 125, 160, 50, '2074', 'Plus/moins-values')}
{box(278, 65, 190, 60, '2042', 'Déclaration revenus', true)}
</svg>
);
}
/* ── Carte "régime de détention" ── */
function RegimeCard({ mode, bucket }) {
const hasData = bucket.plus_value_realisee > 0 || bucket.interet_non_deploye > 0
|| bucket.capital_restitue > 0 || bucket.non_classe > 0 || bucket.pertes_definitives > 0;
return (
<div className="card" style={{ flex: '1 1 300px' }}>
<h3 style={{ marginTop: 0 }}>{MODE_DETENTION_LABELS[mode] || mode}</h3>
{!hasData ? (
<p className="text-muted" style={{ fontSize: 13 }}>Aucun flux pour cette année.</p>
) : (
<div className="kpi-grid">
{bucket.plus_value_realisee > 0 && (
<div className="kpi">
<div className="label">Plus-value réalisée</div>
<div className="value">{fmtEUR(bucket.plus_value_realisee)}</div>
</div>
)}
{bucket.interet_non_deploye > 0 && (
<div className="kpi">
<div className="label">Intérêt sur capital non déployé</div>
<div className="value">{fmtEUR(bucket.interet_non_deploye)}</div>
</div>
)}
{bucket.capital_restitue > 0 && (
<div className="kpi">
<div className="label">Capital restitué <span className="text-muted">(non imposable)</span></div>
<div className="value">{fmtEUR(bucket.capital_restitue)}</div>
</div>
)}
{bucket.non_classe > 0 && (
<div className="kpi">
<div className="label">Distributions non classées <span className="text-muted">(nature inconnue)</span></div>
<div className="value">{fmtEUR(bucket.non_classe)}</div>
</div>
)}
{bucket.pertes_definitives > 0 && (
<div className="kpi">
<div className="label">Pertes définitives</div>
<div className="value danger">{fmtEUR(bucket.pertes_definitives)}</div>
</div>
)}
{bucket.frais_gestion > 0 && (
<div className="kpi">
<div className="label">Frais de gestion <span className="text-muted">(informatif)</span></div>
<div className="value">{fmtEUR(bucket.frais_gestion)}</div>
</div>
)}
</div>
)}
{hasData && mode === 'direct' && (
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
<div className="kpi-grid">
<div className="kpi">
<div className="label">PFU sur plus-value <span className="text-muted">(IR + PS)</span></div>
<div className="value">
{bucket.fiscal.prelev_forfaitaire_plus_value != null
? fmtEUR((bucket.fiscal.prelev_forfaitaire_plus_value || 0) + (bucket.fiscal.prelev_sociaux_plus_value || 0))
: '—'}
</div>
</div>
<div className="kpi">
<div className="label">PFU sur intérêts <span className="text-muted">(IR + PS)</span></div>
<div className="value">
{bucket.fiscal.prelev_forfaitaire_interets != null
? fmtEUR((bucket.fiscal.prelev_forfaitaire_interets || 0) + (bucket.fiscal.prelev_sociaux_interets || 0))
: '—'}
</div>
</div>
</div>
</div>
)}
{hasData && mode === 'pea_pme' && (
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
<div className="kpi-grid">
<div className="kpi">
<div className="label">Prélèvements sociaux <span className="text-muted">(17,2 %)</span></div>
<div className="value">{bucket.fiscal.prelev_sociaux != null ? fmtEUR(bucket.fiscal.prelev_sociaux) : '—'}</div>
</div>
</div>
<p className="text-muted" style={{ fontSize: 12, marginTop: 10, marginBottom: 0 }}>{bucket.fiscal.note}</p>
</div>
)}
{hasData && mode === 'assurance_vie' && (
<p className="text-muted" style={{ fontSize: 12, marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)', marginBottom: 0 }}>
{bucket.fiscal.note}
</p>
)}
</div>
);
}
/**
* TaxReportPe — Fiscalité Private Equity (chantier du 20/09/26, demande Olivier : "Nous n'avons
* pas fait la partie Fiscalité pour les PE"). Pendant PE de TaxReport.jsx (crowdlending), mais
* volontairement plus simple : pas d'onglets CERFA/2042/2047/2778 (aucun de ces formulaires ne
* s'applique directement au PE — cf. commentaire taxreportPe.js), un récapitulatif par mode de
* détention (direct / PEA-PME / assurance-vie, cf. échange avec Olivier : "j'ai besoin de
* différencier les 3") avec un calcul PFU/PS indicatif propre à chaque régime.
*/
export default function TaxReportPe() {
const { activeId, activeView } = useInvestisseur();
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
const [availableYears, setAvailableYears] = useState([]);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('2042');
useEffect(() => {
if (!activeId && activeView !== 'all') return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport-pe/years', scopeParams).then(years => {
setAvailableYears(years);
if (years.length > 0 && !years.map(String).includes(annee)) {
setAnnee(String(years[0]));
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId, activeView]);
const load = () => {
if (!activeId && activeView !== 'all') return;
setData(null);
setLoading(true);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
api.get('/taxreport-pe', { annee, ...scopeParams })
.then(setData)
.finally(() => setLoading(false));
};
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
const downloadCsv = () => {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const exportParams = activeView === 'all' ? { annee, scope: 'all' } : { annee };
fetch(api.exportUrl('/taxreport-pe/export', exportParams), {
headers: { Authorization: `Bearer ${token}`, 'X-Investisseur-Id': investisseurId },
})
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `fiscalite-pe-${annee}.csv`; a.click();
URL.revokeObjectURL(url);
});
};
const hasAnyData = data && (
['direct', 'pea_pme', 'assurance_vie'].some(m => {
const b = data.par_mode_detention[m];
return b.plus_value_realisee > 0 || b.interet_non_deploye > 0 || b.capital_restitue > 0
|| b.non_classe > 0 || b.pertes_definitives > 0;
})
|| (data.non_rattachees && (data.non_rattachees.plus_value_realisee > 0 || data.non_rattachees.interet_non_deploye > 0))
);
if (!loading && data && availableYears.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="tax" />Fiscalité — Private Equity</h2></div>
<EmptyState
icon="📋"
title="Aucune donnée fiscale PE pour le moment"
message="Vos deals PE sont configurés, mais aucune distribution n'a encore été enregistrée. Ajoutez une distribution (page Remboursements) pour voir apparaître votre récapitulatif fiscal."
cta="Ajouter une distribution"
to="/remboursements?new=1"
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="tax" />Fiscalité — Private Equity</h2>
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch' }}>
<button type="button" className="icon-btn" disabled={!data} onClick={downloadCsv} title="Exporter en CSV">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
</div>
</div>
{loading || !data ? (
<div className="card text-muted">Chargement…</div>
) : !hasAnyData ? (
<EmptyState
icon="📋"
title={`Aucune donnée fiscale PE pour ${annee}`}
message="Aucune distribution ni perte définitive enregistrée sur cette année. Changez d'année ou ajoutez des mouvements."
cta="Ajouter une distribution"
to="/remboursements?new=1"
/>
) : (
<>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'stretch' }}>
<RegimeCard mode="direct" bucket={data.par_mode_detention.direct} />
<RegimeCard mode="pea_pme" bucket={data.par_mode_detention.pea_pme} />
<RegimeCard mode="assurance_vie" bucket={data.par_mode_detention.assurance_vie} />
</div>
<div className="card" style={{ marginTop: 16 }}>
<h3 style={{ marginTop: 0, marginBottom: 10 }}>À quoi correspond chaque CERFA ?</h3>
<div style={{ display: 'flex', gap: 28, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', flexDirection: 'column', gap: 12 }}>
<InfoLigneCerfa
code="2042"
titre="Déclaration des revenus"
texte="La déclaration principale : reprend la case 2TR (intérêt sur capital non déployé) et, le cas échéant, les cases 3VG / 3VH (plus ou moins-value de cession) reportées depuis la 2074 — pour vos deals en détention directe uniquement."
/>
<InfoLigneCerfa
code="2561"
titre="IFU — Imprimé fiscal unique"
texte="Récapitulatif annuel transmis par les plateformes pour vos deals en détention directe soumis au PFU. Ne couvre que l'intérêt sur capital non déployé (case AR → 2TR) : les plus-values de cession n'y figurent jamais."
/>
<InfoLigneCerfa
code="2074"
titre="Plus ou moins-values de cession de valeurs mobilières"
texte="À déclarer vous-même, séparément de l'IFU : la plus-value réalisée (case 3VG) ou la moins-value définitive constatée (case 3VH) sur un deal en détention directe. Le résultat net alimente ensuite la 2042."
/>
</div>
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', justifyContent: 'center' }}>
<CerfaFlowDiagramPe />
</div>
</div>
<p className="text-muted" style={{ marginTop: 14, marginBottom: 0, fontSize: 12, lineHeight: 1.6 }}>
⚠ Ce module est une aide à la préparation de votre déclaration ; il ne la remplace pas. Les onglets et cases
ci-dessous ne concernent que vos deals en <strong>détention directe</strong> — le PEA-PME et l'assurance-vie
suivent des régimes fiscaux distincts, sans formulaire CERFA dédié (cf. cartes ci-dessus). Vérifiez chaque
montant avant de le reporter. Vous restez seul responsable de l'exactitude de votre déclaration fiscale —
Crowdlending ne saurait être tenu responsable des données déclarées.
</p>
</div>
<div className="dr-tabs">
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
<button className={`dr-tab${activeTab === '2561' ? ' active' : ''}`} onClick={() => setActiveTab('2561')}>CERFA 2561 (IFU)</button>
</div>
{activeTab === '2042' && <Cerfa2042Pe annee={annee} activeView={activeView} />}
{activeTab === '2561' && <Cerfa2561Pe annee={annee} activeView={activeView} />}
{data.non_rattachees && (data.non_rattachees.plus_value_realisee > 0 || data.non_rattachees.interet_non_deploye > 0 || data.non_rattachees.non_classe > 0) && (
<div className="card" style={{ marginTop: 16, borderColor: 'var(--warning, #d97706)' }}>
<h3 style={{ marginTop: 0 }}>⚠ Distributions non rattachées à un deal</h3>
<p className="text-muted" style={{ fontSize: 13 }}>
Ces distributions n'indiquent pas de deal PE précis (champ « Investissement PE »
du formulaire) — leur régime de détention (direct / PEA-PME / assurance-vie) ne
peut donc pas être déterminé, et elles ne sont pas comptées dans les cartes
ci-dessus. Rattachez-les à leur deal (page Remboursements) pour qu'elles soient
prises en compte.
</p>
<div className="kpi-grid">
{data.non_rattachees.plus_value_realisee > 0 && (
<div className="kpi"><div className="label">Plus-value réalisée</div><div className="value">{fmtEUR(data.non_rattachees.plus_value_realisee)}</div></div>
)}
{data.non_rattachees.interet_non_deploye > 0 && (
<div className="kpi"><div className="label">Intérêt sur capital non déployé</div><div className="value">{fmtEUR(data.non_rattachees.interet_non_deploye)}</div></div>
)}
{data.non_rattachees.non_classe > 0 && (
<div className="kpi"><div className="label">Non classées</div><div className="value">{fmtEUR(data.non_rattachees.non_classe)}</div></div>
)}
</div>
</div>
)}
{data.pertes_detail && data.pertes_detail.length > 0 && (
<div className="card" style={{ marginTop: 16 }}>
<h3 style={{ marginTop: 0 }}>Pertes définitives déclarées en {annee}</h3>
<table>
<thead>
<tr>
<th>Date d'effet</th>
<th>Deal</th>
<th>Plateforme</th>
<th>Mode de détention</th>
<th>Motif</th>
<th className="num">Montant</th>
</tr>
</thead>
<tbody>
{data.pertes_detail.map((p, i) => (
<tr key={i}>
<td>{fmtDate(p.date_effet)}</td>
<td>{p.nom_deal}</td>
<td>{p.plateforme_nom}</td>
<td>{MODE_DETENTION_LABELS[p.mode_detention] || p.mode_detention}</td>
<td className="text-muted">{p.motif}</td>
<td className="num">{fmtEUR(p.montant_perte)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-muted" style={{ fontSize: 12, marginTop: 16 }}>
{data.cases?.note}
</p>
</>
)}
</>
);
}