MAJ
This commit is contained in:
1 parent
14cf4e5a19
commit
26197fc411
15 files changed
+1776
-29
No files matched your search
@@ -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); } },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user