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); } },
]}
/>