MAJ
This commit is contained in:
1 parent
56d52d538e
commit
0df335803e
2 files changed
+176
-286
No files matched your search
@@ -72,6 +72,19 @@ export default function RembFormModal({
|
||||
multiDetenteur = false,
|
||||
confirmingDelete, setConfirmingDelete,
|
||||
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 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
|
||||
// "Frais d'opération" de la fiche investissement, cf. project_frais_operation.md.
|
||||
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'));
|
||||
|
||||
@@ -163,7 +176,7 @@ export default function RembFormModal({
|
||||
{/* ── Sélecteur d'investissement (mode non verrouillé uniquement) ── */}
|
||||
{!locked && (
|
||||
<div style={{ gridColumn: isPlateformeMode ? 'span 1' : 'span 3' }}>
|
||||
<label>Investissement *</label>
|
||||
<label>{investissementLabel}</label>
|
||||
<InvestissementSelect
|
||||
value={form.investissement_id}
|
||||
onChange={v => onFieldChange('investissement_id', v)}
|
||||
@@ -183,6 +196,15 @@ export default function RembFormModal({
|
||||
</select>
|
||||
</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 && (
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.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 EmptyState from '../components/EmptyState.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 = {
|
||||
type: 'bonus_plateforme',
|
||||
investissement_id: 'BONUS_PLATEFORME',
|
||||
bonus_plateforme_id: '',
|
||||
bonus_investisseur_id: '',
|
||||
// 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).
|
||||
*/
|
||||
export default function RemboursementsSimple() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { activeId: investisseurId } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -185,7 +209,8 @@ export default function RemboursementsSimple() {
|
||||
const [deals, setDeals] = useState([]);
|
||||
// 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
|
||||
// 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 [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
@@ -194,6 +219,10 @@ export default function RemboursementsSimple() {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyRembForm);
|
||||
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();
|
||||
|
||||
// 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 || '');
|
||||
setForm({
|
||||
...emptyRembForm,
|
||||
...(deal ? { type: 'interets_plateforme' } : {}),
|
||||
investissement_id: deal ? INTERETS_PLATEFORME_VALUE : emptyRembForm.investissement_id,
|
||||
bonus_plateforme_id: firstId,
|
||||
bonus_investisseur_id: investisseurForPlat(firstId),
|
||||
investissement_pe_id: deal ? String(deal.id) : '',
|
||||
});
|
||||
setErr(null);
|
||||
setConfirmingDelete(false);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -304,7 +334,7 @@ export default function RemboursementsSimple() {
|
||||
const openEdit = (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
type: r.type,
|
||||
investissement_id: TYPE_TO_SENTINEL[r.type] || INTERETS_PLATEFORME_VALUE,
|
||||
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
|
||||
bonus_investisseur_id: String(r.bonus_investisseur_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 ?? ''),
|
||||
});
|
||||
setErr(null);
|
||||
setConfirmingDelete(false);
|
||||
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) => {
|
||||
e?.preventDefault?.(); setErr(null);
|
||||
const base = {
|
||||
type: form.type,
|
||||
type: isBonusForm ? BONUS_TYPE_MAP[form.investissement_id] : 'interets_plateforme',
|
||||
bonus_plateforme_id: Number(form.bonus_plateforme_id),
|
||||
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
|
||||
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
|
||||
@@ -336,8 +423,12 @@ export default function RemboursementsSimple() {
|
||||
statut: form.statut,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
const payload = form.type === 'interets_plateforme'
|
||||
const payload = isBonusForm
|
||||
? {
|
||||
...base,
|
||||
cashback: Number(form.cashback) || 0,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
|
||||
taxe_locale: Number(form.taxe_locale) || 0,
|
||||
@@ -345,10 +436,6 @@ export default function RemboursementsSimple() {
|
||||
frais_ttc: Number(form.frais_ttc) || 0,
|
||||
prelev_sociaux: Number(form.prelev_sociaux) || 0,
|
||||
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
cashback: Number(form.cashback) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
|
||||
@@ -358,6 +445,7 @@ export default function RemboursementsSimple() {
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
// Suppression depuis l'icône ✕ d'une ligne du tableau (flux ConfirmModal existant).
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
@@ -367,6 +455,15 @@ export default function RemboursementsSimple() {
|
||||
} 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(
|
||||
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
|
||||
[rows],
|
||||
@@ -489,24 +586,29 @@ export default function RemboursementsSimple() {
|
||||
cta="Ajouter un remboursement"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
<Modal
|
||||
<RembFormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? 'Création…' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFieldChange={setField}
|
||||
onFormPatch={onFormPatch}
|
||||
err={err}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDeleteEditing)}
|
||||
submitting={submitting}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
pfuRates={pfuRates}
|
||||
comptesInvestisseur={[]}
|
||||
investissements={[]}
|
||||
plateformes={plats}
|
||||
investissementOptions={[]}
|
||||
multiDetenteur={multiDetenteur}
|
||||
investissementLabel="Type *"
|
||||
dealOptions={dealsForSelectedPlat}
|
||||
forceShowFrais
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -754,34 +856,29 @@ export default function RemboursementsSimple() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<RembFormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{editingId && (
|
||||
<button className="danger" disabled={submitting}
|
||||
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFieldChange={setField}
|
||||
onFormPatch={onFormPatch}
|
||||
err={err}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDeleteEditing)}
|
||||
submitting={submitting}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
pfuRates={pfuRates}
|
||||
comptesInvestisseur={[]}
|
||||
investissements={[]}
|
||||
plateformes={plats}
|
||||
investissementOptions={[]}
|
||||
multiDetenteur={multiDetenteur}
|
||||
investissementLabel="Type *"
|
||||
dealOptions={dealsForSelectedPlat}
|
||||
forceShowFrais
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user