This commit is contained in:
ocroguennec committed 2026-09-18 07:48:55 +02:00
1 parent 56d52d538e
commit 0df335803e
2 files changed
+176 -286

No files matched your search

+24 -2
View File
@@ -72,6 +72,19 @@ export default function RembFormModal({
multiDetenteur = false, multiDetenteur = false,
confirmingDelete, setConfirmingDelete, confirmingDelete, setConfirmingDelete,
submitting = false, submitting = false,
// Libellé du sélecteur du haut (17/09/26, cf. RemboursementsSimple.jsx — pages PE,
// qui n'ont pas de notion d'"investissement" mais réutilisent ce même sélecteur pour
// choisir entre les 3 types bonus_parrainage/bonus_plateforme/interets_plateforme).
investissementLabel = 'Investissement *',
// Deals PE éligibles à la plateforme sélectionnée (17/09/26, cf. RemboursementsSimple.jsx) :
// permet de rattacher une distribution bonus/intérêts à un deal précis pour son TVPI.
// Vide par défaut (aucun impact pour les pages crowdlending qui ne passent pas cette prop).
dealOptions = [],
// Force l'affichage de la section Frais même quand la plateforme n'a pas de
// frais_plateforme configuré (17/09/26, cf. RemboursementsSimple.jsx : référentiel
// crowdlending-only qu'un deal PE n'a aucune raison d'avoir renseigné — sans ce
// forçage, le champ "Frais TTC" disparaissait pour toute nouvelle distribution PE).
forceShowFrais = false,
}) { }) {
const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id); const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id);
const isInteretsPlateforme = !locked && form.investissement_id === INTERETS_PLATEFORME_VALUE; const isInteretsPlateforme = !locked && form.investissement_id === INTERETS_PLATEFORME_VALUE;
@@ -104,7 +117,7 @@ export default function RembFormModal({
// l'accès même si la config de la plateforme change ensuite) — même logique que le bloc // l'accès même si la config de la plateforme change ensuite) — même logique que le bloc
// "Frais d'opération" de la fiche investissement, cf. project_frais_operation.md. // "Frais d'opération" de la fiche investissement, cf. project_frais_operation.md.
const showFraisSection = !isBonus const showFraisSection = !isBonus
&& ((currentPlat?.frais_plateforme && currentPlat.frais_plateforme !== 'aucun') || Number(form.frais_ttc) > 0); && (forceShowFrais || (currentPlat?.frais_plateforme && currentPlat.frais_plateforme !== 'aucun') || Number(form.frais_ttc) > 0);
const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement')); const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement'));
@@ -163,7 +176,7 @@ export default function RembFormModal({
{/* ── Sélecteur d'investissement (mode non verrouillé uniquement) ── */} {/* ── Sélecteur d'investissement (mode non verrouillé uniquement) ── */}
{!locked && ( {!locked && (
<div style={{ gridColumn: isPlateformeMode ? 'span 1' : 'span 3' }}> <div style={{ gridColumn: isPlateformeMode ? 'span 1' : 'span 3' }}>
<label>Investissement *</label> <label>{investissementLabel}</label>
<InvestissementSelect <InvestissementSelect
value={form.investissement_id} value={form.investissement_id}
onChange={v => onFieldChange('investissement_id', v)} onChange={v => onFieldChange('investissement_id', v)}
@@ -183,6 +196,15 @@ export default function RembFormModal({
</select> </select>
</div> </div>
)} )}
{!locked && isPlateformeMode && dealOptions.length > 0 && (
<div>
<label>Investissement PE (optionnel)</label>
<select value={form.investissement_pe_id || ''} onChange={e => onFormPatch({ investissement_pe_id: e.target.value })}>
<option value="">— Aucun (non rattaché à un deal) —</option>
{dealOptions.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
</select>
</div>
)}
{!locked && isBonus && ( {!locked && isBonus && (
<div> <div>
<label>Date *</label> <label>Date *</label>
+152 -284
View File
@@ -3,7 +3,7 @@ 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';
import { useWorkspace } from '../context/WorkspaceContext.jsx'; import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx'; import RembFormModal from '../components/RembFormModal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx'; import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx'; import PageIcon from '../components/PageIcon.jsx';
@@ -132,8 +132,32 @@ function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
); );
} }
// Sélecteur du haut de RembFormModal (17/09/26, cf. InvestissementSelect.jsx) : mêmes
// 3 sentinelles que Remboursements.jsx crowdlending, en lieu et place d'un champ `type`
// dédié — permet de réutiliser RembFormModal tel quel (même formulaire, même gestion de
// la fiscalité) plutôt que de dupliquer sa logique, à la demande d'Olivier ("la même
// chose que pour le crowdlending").
const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
const INTERETS_PLATEFORME_VALUE = 'INTERETS_PLATEFORME';
const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' };
const TYPE_TO_SENTINEL = {
bonus_parrainage: 'BONUS_PARRAINAGE',
bonus_plateforme: 'BONUS_PLATEFORME',
interets_plateforme: INTERETS_PLATEFORME_VALUE,
};
// Helpers purs pour le calcul fiscal (mêmes formules que RembFormModal.jsx / Remboursements.jsx
// crowdlending), dupliqués volontairement (pas d'état) : servent au setField() de cette page.
const round2 = (n) => Math.round(n * 100) / 100;
const getRatesForYearPe = (dateStr, pfuRates) => {
if (!dateStr || !pfuRates?.length) return null;
const year = parseInt(dateStr.slice(0, 4), 10);
return pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
};
const emptyRembForm = { const emptyRembForm = {
type: 'bonus_plateforme', investissement_id: 'BONUS_PLATEFORME',
bonus_plateforme_id: '', bonus_plateforme_id: '',
bonus_investisseur_id: '', bonus_investisseur_id: '',
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de // Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
@@ -175,7 +199,7 @@ const emptyRembForm = {
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26). * X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
*/ */
export default function RemboursementsSimple() { export default function RemboursementsSimple() {
const { activeId: investisseurId, investisseurs } = useInvestisseur(); const { activeId: investisseurId } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {}; const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -185,7 +209,8 @@ export default function RemboursementsSimple() {
const [deals, setDeals] = useState([]); const [deals, setDeals] = useState([]);
// Taux PFU par année (17/09/26, cf. project ask Olivier : aligner la gestion de la // Taux PFU par année (17/09/26, cf. project ask Olivier : aligner la gestion de la
// fiscalité PE sur celle de Remboursements.jsx crowdlending) — sert au calcul // fiscalité PE sur celle de Remboursements.jsx crowdlending) — sert au calcul
// automatique des prélèvements sociaux/forfaitaire, cf. RemboursementForm ci-dessous. // automatique des prélèvements sociaux/forfaitaire dans setField() ci-dessous, passé
// à RembFormModal (composant partagé avec Remboursements.jsx crowdlending).
const [pfuRates, setPfuRates] = useState([]); const [pfuRates, setPfuRates] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
@@ -194,6 +219,10 @@ export default function RemboursementsSimple() {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyRembForm); const [form, setForm] = useState(emptyRembForm);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
// Confirmation de suppression inline dans RembFormModal (bouton "Supprimer" du
// formulaire lui-même) — distinct de deleteTarget/ConfirmModal ci-dessus, qui reste
// le flux de suppression depuis l'icône ✕ d'une ligne du tableau.
const [confirmingDelete, setConfirmingDelete] = useState(false);
const { run: runSubmit, pending: submitting } = useAsyncAction(); const { run: runSubmit, pending: submitting } = useAsyncAction();
// Plateformes éligibles au workspace actif uniquement (GET /plateformes // Plateformes éligibles au workspace actif uniquement (GET /plateformes
@@ -258,12 +287,13 @@ export default function RemboursementsSimple() {
const firstId = deal ? deal.plateforme_id : (plats[0]?.id || ''); const firstId = deal ? deal.plateforme_id : (plats[0]?.id || '');
setForm({ setForm({
...emptyRembForm, ...emptyRembForm,
...(deal ? { type: 'interets_plateforme' } : {}), investissement_id: deal ? INTERETS_PLATEFORME_VALUE : emptyRembForm.investissement_id,
bonus_plateforme_id: firstId, bonus_plateforme_id: firstId,
bonus_investisseur_id: investisseurForPlat(firstId), bonus_investisseur_id: investisseurForPlat(firstId),
investissement_pe_id: deal ? String(deal.id) : '', investissement_pe_id: deal ? String(deal.id) : '',
}); });
setErr(null); setErr(null);
setConfirmingDelete(false);
setModalOpen(true); setModalOpen(true);
}; };
@@ -304,7 +334,7 @@ export default function RemboursementsSimple() {
const openEdit = (r) => { const openEdit = (r) => {
setEditingId(r.id); setEditingId(r.id);
setForm({ setForm({
type: r.type, investissement_id: TYPE_TO_SENTINEL[r.type] || INTERETS_PLATEFORME_VALUE,
bonus_plateforme_id: String(r.bonus_plateforme_id || ''), bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
bonus_investisseur_id: String(r.bonus_investisseur_id || ''), bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '', investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
@@ -320,15 +350,72 @@ export default function RemboursementsSimple() {
prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''), prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''),
}); });
setErr(null); setErr(null);
setConfirmingDelete(false);
setModalOpen(true); setModalOpen(true);
}; };
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); }; const close = () => {
setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); setConfirmingDelete(false);
};
// Recalcule automatiquement taxe locale / prélèvements PFU à chaque changement d'un
// champ "moteur" — même logique que setField() dans Remboursements.jsx crowdlending
// (cf. RembFormModal.jsx, qui appelle onFieldChange pour ces mêmes clés). L'utilisateur
// garde la main pour corriger prelev_sociaux/prelev_forfaitaire ensuite : ces deux champs
// ne redéclenchent pas ce recalcul (passent par onFormPatch, pas par ici).
const setField = (key, value) => {
// Le sélecteur "Type *" (InvestissementSelect, partagé avec Remboursements.jsx
// crowdlending) inclut toujours une option vide "—" — pertinente côté crowdlending
// (état initial "rien choisi") mais jamais côté PE, qui n'a que 3 types valides et
// aucun état "aucun type" à gérer. On ignore ce choix plutôt que de laisser le
// formulaire basculer sur la branche "remboursement normal" de RembFormModal
// (Capital/Méthode de remboursement...), sans rapport avec un deal PE.
if (key === 'investissement_id' && value === '') return;
const next = { ...form, [key]: value };
if (key === 'interets_bruts_avant_local') {
const plat = plats.find(p => String(p.id) === String(next.bonus_plateforme_id));
const taux = plat?.fiscalite === 'avec_fiscalite_locale' ? (plat?.taux_fiscalite_locale || 0) : 0;
next.taxe_locale = round2(Number(value) * taux / 100);
next.interets_bruts = round2(Number(value) - next.taxe_locale);
}
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)) {
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) {
next.prelev_sociaux = round2(base * rates.prelev_sociaux / 100);
next.prelev_forfaitaire = round2(base * rates.impot_revenu / 100);
}
}
setForm(next);
};
// Changement de plateforme associée : réinitialise le deal PE lié (peut ne plus être
// éligible à la nouvelle plateforme) — passe par onFormPatch (pas setField), comme le
// <select> Plateforme de RembFormModal qui construit lui-même son patch complet.
const onFormPatch = (patch) => {
setForm(f => {
const next = { ...f, ...patch };
if ('bonus_plateforme_id' in patch) next.investissement_pe_id = '';
return next;
});
};
const isBonusForm = BONUS_VALUES.includes(form.investissement_id);
// Deals PE de la plateforme sélectionnée dans le formulaire (RembFormModal,
// dealOptions) — un remboursement bonus/intérêts peut être rattaché à un deal
// précis, pour le calcul de son TVPI (cf. pages/InvestissementsPe.jsx).
const dealsForSelectedPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
const submit = async (e) => { const submit = async (e) => {
e?.preventDefault?.(); setErr(null); e?.preventDefault?.(); setErr(null);
const base = { const base = {
type: form.type, type: isBonusForm ? BONUS_TYPE_MAP[form.investissement_id] : 'interets_plateforme',
bonus_plateforme_id: Number(form.bonus_plateforme_id), bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined, bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
investissement_pe_id: Number(form.investissement_pe_id) || undefined, investissement_pe_id: Number(form.investissement_pe_id) || undefined,
@@ -336,8 +423,12 @@ export default function RemboursementsSimple() {
statut: form.statut, statut: form.statut,
notes: form.notes || undefined, notes: form.notes || undefined,
}; };
const payload = form.type === 'interets_plateforme' const payload = isBonusForm
? { ? {
...base,
cashback: Number(form.cashback) || 0,
}
: {
...base, ...base,
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0, interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
taxe_locale: Number(form.taxe_locale) || 0, taxe_locale: Number(form.taxe_locale) || 0,
@@ -345,10 +436,6 @@ export default function RemboursementsSimple() {
frais_ttc: Number(form.frais_ttc) || 0, frais_ttc: Number(form.frais_ttc) || 0,
prelev_sociaux: Number(form.prelev_sociaux) || 0, prelev_sociaux: Number(form.prelev_sociaux) || 0,
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0, prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
}
: {
...base,
cashback: Number(form.cashback) || 0,
}; };
try { try {
if (editingId) await api.put(`/remboursements/${editingId}`, payload); if (editingId) await api.put(`/remboursements/${editingId}`, payload);
@@ -358,6 +445,7 @@ export default function RemboursementsSimple() {
} catch (e) { setErr(e.message); } } catch (e) { setErr(e.message); }
}; };
// Suppression depuis l'icône ✕ d'une ligne du tableau (flux ConfirmModal existant).
const onDelete = async () => { const onDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
@@ -367,6 +455,15 @@ export default function RemboursementsSimple() {
} catch (e) { setErr(e.message); } } catch (e) { setErr(e.message); }
}; };
// Suppression depuis le bouton "Supprimer" du formulaire lui-même (confirmation inline
// de RembFormModal — même pattern que Remboursements.jsx crowdlending).
const onDeleteEditing = async () => {
if (!editingId) return;
await api.del(`/remboursements/${editingId}`);
close();
await load();
};
const sorted = useMemo( const sorted = useMemo(
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id), () => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
[rows], [rows],
@@ -489,24 +586,29 @@ export default function RemboursementsSimple() {
cta="Ajouter un remboursement" cta="Ajouter un remboursement"
to="/remboursements?new=1" to="/remboursements?new=1"
/> />
<Modal <RembFormModal
open={modalOpen} open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close} onClose={close}
busy={submitting} editingId={editingId}
footer={ form={form}
<div style={{ display: 'flex', gap: 8 }}> onFieldChange={setField}
<button disabled={submitting} onClick={close}>Annuler</button> onFormPatch={onFormPatch}
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}> err={err}
{submitting ? 'Création…' : 'Créer'} onSubmit={e => runSubmit(submit, e)}
</button> onDelete={() => runSubmit(onDeleteEditing)}
</div> submitting={submitting}
} confirmingDelete={confirmingDelete}
> setConfirmingDelete={setConfirmingDelete}
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals} pfuRates={pfuRates}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} comptesInvestisseur={[]}
investisseurForPlat={investisseurForPlat} editingId={editingId} pfuRates={pfuRates} /> investissements={[]}
</Modal> plateformes={plats}
investissementOptions={[]}
multiDetenteur={multiDetenteur}
investissementLabel="Type *"
dealOptions={dealsForSelectedPlat}
forceShowFrais
/>
</> </>
); );
@@ -754,34 +856,29 @@ export default function RemboursementsSimple() {
</div> </div>
)} )}
<Modal <RembFormModal
open={modalOpen} open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close} onClose={close}
busy={submitting} editingId={editingId}
footer={ form={form}
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}> onFieldChange={setField}
<div> onFormPatch={onFormPatch}
{editingId && ( err={err}
<button className="danger" disabled={submitting} onSubmit={e => runSubmit(submit, e)}
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}> onDelete={() => runSubmit(onDeleteEditing)}
Supprimer submitting={submitting}
</button> confirmingDelete={confirmingDelete}
)} setConfirmingDelete={setConfirmingDelete}
</div> pfuRates={pfuRates}
<div style={{ display: 'flex', gap: 8 }}> comptesInvestisseur={[]}
<button disabled={submitting} onClick={close}>Annuler</button> investissements={[]}
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}> plateformes={plats}
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')} investissementOptions={[]}
</button> multiDetenteur={multiDetenteur}
</div> investissementLabel="Type *"
</div> dealOptions={dealsForSelectedPlat}
} forceShowFrais
> />
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} pfuRates={pfuRates} />
</Modal>
<ConfirmModal <ConfirmModal
open={!!deleteTarget} open={!!deleteTarget}
@@ -794,232 +891,3 @@ export default function RemboursementsSimple() {
</> </>
); );
} }
/* ── Fiscalité (17/09/26, cf. Remboursements.jsx / RembFormModal.jsx crowdlending,
* à la demande d'Olivier : la saisie d'une distribution PE doit s'appuyer sur les
* mêmes taux PFU et la même logique indicatif/retenue-à-la-source, pas sur des champs
* prélèvements en saisie libre). Helpers purs, dupliqués volontairement (pas d'état) —
* même formules que RembFormModal.jsx. ── */
const round2 = (n) => Math.round(n * 100) / 100;
const fmtRatePfu = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
const getRatesForYearPe = (dateStr, pfuRates) => {
if (!dateStr || !pfuRates?.length) return null;
const year = parseInt(dateStr.slice(0, 4), 10);
return pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
};
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
* points d'entrée (page avec mouvements existants, et état vide). Champs
* strictement alignés sur le Schema de backend/src/routes/remboursements.js,
* limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
* (le type "normal", lié à un investissement, reste crowdlending uniquement). */
function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId, pfuRates }) {
const isInterets = form.type === 'interets_plateforme';
// Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts
// peut être rattaché à un deal précis, pour le calcul de son TVPI — cf.
// pages/InvestissementsPe.jsx). Optionnel : plusieurs plateformes n'ont
// qu'un seul deal, ou aucun deal PE suivi pour l'instant.
const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
// ── Fiscalité (même logique que RembFormModal.jsx crowdlending) ────────
const currentPlat = plats.find(p => String(p.id) === String(form.bonus_plateforme_id));
const hasLocalTax = isInterets && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
// Pas d'équivalent fiscalite_override côté investissements_pe (pas de notion
// d'exonération par deal) : seule la fiscalité de la plateforme détermine le
// caractère indicatif, contrairement à crowdlending qui croise aussi
// investissement.fiscalite_override — cf. table investissements_pe (db/index.js).
const isIndicatif = isInterets && Boolean(currentPlat && currentPlat.fiscalite !== 'flat_tax');
const rates = isInterets ? getRatesForYearPe(form.date_remb, pfuRates) : null;
const labelPS = `Prélèvements sociaux${rates ? ` (${fmtRatePfu(rates.prelev_sociaux)})` : ''} (€)`;
const labelIR = `Impôt sur le revenu${rates ? ` (${fmtRatePfu(rates.impot_revenu)})` : ''} (€)`;
// Recalcule automatiquement taxe locale / prélèvements PFU quand un champ
// "moteur" change — l'utilisateur garde la main pour corriger prelev_sociaux/
// prelev_forfaitaire ensuite (ces deux champs ne redéclenchent pas ce recalcul,
// exactement comme setField()/onFormPatch() dans Remboursements.jsx).
const setFiscalField = (key, value) => {
const next = { ...form, [key]: value };
if (key === 'interets_bruts_avant_local') {
const taux = hasLocalTax ? (currentPlat.taux_fiscalite_locale || 0) : 0;
next.taxe_locale = round2(Number(value) * taux / 100);
next.interets_bruts = round2(Number(value) - next.taxe_locale);
}
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)) {
const dateStr = key === 'date_remb' ? value : next.date_remb;
const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0));
const r = getRatesForYearPe(dateStr, pfuRates);
if (r) {
next.prelev_sociaux = round2(base * r.prelev_sociaux / 100);
next.prelev_forfaitaire = round2(base * r.impot_revenu / 100);
}
}
setForm(next);
};
const interetsNets = round2(
(Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0)
- (Number(form.prelev_sociaux) || 0) - (Number(form.prelev_forfaitaire) || 0)
);
const previewNet = () => {
if (isInterets) {
return isIndicatif
? round2((Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0))
: interetsNets;
}
return Number(form.cashback) || 0;
};
return (
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Type *</label>
<select required value={form.type} disabled={!!editingId}
title={editingId ? 'Le type ne peut pas être modifié après création' : undefined}
onChange={e => setForm({ ...form, type: e.target.value })}>
<option value="bonus_plateforme">Bonus plateforme</option>
<option value="bonus_parrainage">Bonus parrainage</option>
<option value="interets_plateforme">Intérêts plateforme</option>
</select>
</div>
<div>
<label>Plateforme *</label>
<select required value={form.bonus_plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, bonus_plateforme_id: platId, bonus_investisseur_id: investisseurForPlat(platId), investissement_pe_id: '' }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
{dealsForPlat.length > 0 && (
<div>
<label>Investissement (optionnel)</label>
<select value={form.investissement_pe_id} onChange={e => setForm({ ...form, investissement_pe_id: e.target.value })}>
<option value="">— Aucun (non rattaché à un deal) —</option>
{dealsForPlat.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
</select>
</div>
)}
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb} onChange={e => setForm({ ...form, date_remb: e.target.value })} />
</div>
{!isInterets && (
<div>
<label>Montant reçu (€) *</label>
<input type="number" step="0.01" min="0" required value={form.cashback} onChange={e => setForm({ ...form, cashback: e.target.value })} />
</div>
)}
{isInterets && (
<>
{hasLocalTax && (
<div>
<label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) (€)</label>
<input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local}
onChange={e => setFiscalField('interets_bruts_avant_local', e.target.value)} />
</div>
)}
{hasLocalTax && (
<div>
<label>Retenue à la source locale (€)</label>
<input type="number" step="0.01" min="0" value={form.taxe_locale}
onChange={e => setFiscalField('taxe_locale', e.target.value)} />
</div>
)}
<div>
<label>{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€) *'}</label>
<input type="number" step="0.01" min="0" required={!hasLocalTax} value={form.interets_bruts}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && setFiscalField('interets_bruts', e.target.value)} />
</div>
<div>
<label>Frais TTC (€)</label>
<input type="number" step="0.01" min="0" value={form.frais_ttc}
onChange={e => setFiscalField('frais_ttc', e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>
Imposition{isIndicatif ? ' — indicatif' : ''}
</span>
</div>
<div>
<label>{labelPS}</label>
<input type="number" step="0.01" value={form.prelev_sociaux}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_sociaux: e.target.value })} />
</div>
<div>
<label>{labelIR}</label>
<input type="number" step="0.01" value={form.prelev_forfaitaire}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_forfaitaire: e.target.value })} />
</div>
<div>
<label>Montant net perçu (€)</label>
<input type="number" step="0.01" readOnly value={previewNet()}
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 12 }}>
{isIndicatif
? "L'imposition n'est pas déduite du montant net perçu (fiscalité appliquée à titre indicatif — plateforme hors flat tax)."
: "L'imposition est déduite du montant net perçu (intérêts bruts − frais − prélèvements)."}
</div>
</>
)}
<div>
<label>Statut</label>
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
<option value="paye">Payé</option>
<option value="retard">En retard</option>
<option value="partiel">Partiel</option>
<option value="impaye">Impayé</option>
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.bonus_investisseur_id} onChange={e => setForm({ ...form, bonus_investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
{!isInterets && (
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}>
Net perçu estimé : {fmtEUR(previewNet())}
</div>
)}
</div>
</form>
);
}