Nouvelles features: revision des conditions des prêts
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# MEMORY.md — Crowdlending Tracker
|
# MEMORY.md — Crowdlending Tracker
|
||||||
*Dernière mise à jour: 2026-07-03 (session 8)*
|
*Dernière mise à jour: 2026-07-12 (session 10)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -508,3 +508,35 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
|||||||
### Piège outillage — mount bash périmé après édition Edit/Write
|
### Piège outillage — mount bash périmé après édition Edit/Write
|
||||||
- Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
|
- Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
|
||||||
- **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`).
|
- **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 10 — Bug racine dates prêts différés + audit trail + statuts auto (2026-07-12)
|
||||||
|
|
||||||
|
### 🔴 Bug racine trouvé et corrigé — migration `db/index.js` recalculait `date_cible` à CHAQUE démarrage
|
||||||
|
- **Symptôme initial** : des dizaines de prêts `differe` avec `date_cible` aberrante (années 2100 à 2650), sans aucune trace dans `investissement_historique`, y compris des cas de "ping-pong" de statut (`en_cours` ↔ `en_retard`) sur les mêmes prêts toutes les quelques minutes.
|
||||||
|
- **Cause réelle** (migration "renommage `date_debut`→`date_premiere_echeance`, `date_echeance`→`date_cible`", `backend/src/db/index.js` ~ligne 229) : le bloc de "correction de formule historique" (`SET date_cible = date(date_premiere_echeance, '+(duree_mois-1)' months)`) n'avait **aucune garde** (`WHERE date_cible IS NULL`) — il tournait donc à **chaque redémarrage serveur**, pour **tous** les investissements. Combiné à une 2e migration juste après (`date_premiere_echeance = date_cible` pour les prêts différés, censée maintenir l'égalité des deux dates), cela formait une **boucle infinie** : à chaque redémarrage, `date_cible` dérivait de +(duree_mois-1) mois supplémentaires — jamais tracé, car du code de migration, pas une action utilisateur ni une route API.
|
||||||
|
- **Pourquoi ça n'a été détecté que maintenant** : le serveur redémarre très souvent en dev (`node --watch`), donc chaque édition de code déclenchait un cycle de dérive supplémentaire sur les prêts différés déjà touchés.
|
||||||
|
- **Fix** : le bloc de correction ne s'exécute désormais que si `migrationEnCours` est vrai (colonnes `date_debut`/`date_echeance` encore présentes, càd le jour réel du renommage) — plus jamais au démarrage normal. La migration `date_premiere_echeance = date_cible` (prêts différés) enregistre maintenant un historique précis (`correction_auto_echeancier`) quand elle modifie quelque chose.
|
||||||
|
- **Point d'attention pour le futur** : toute migration de données (pas juste `ALTER TABLE ADD COLUMN`) dans `db/index.js` doit être strictement idempotente/one-shot — soit via clause `WHERE champ IS NULL`, soit gardée derrière la détection de l'événement historique qui la justifie. Ne jamais laisser un `UPDATE` sans garde tourner à chaque boot.
|
||||||
|
|
||||||
|
### Autres endroits déjà audités/corrigés pour la même classe de bug (dates modifiées sans trace)
|
||||||
|
- `POST /api/imports/dossier` (`imports.js`) : la branche "SCÉNARIO UPDATE" (upsert par nom_projet+date_souscription) écrasait `date_cible`/`date_premiere_echeance`/`montant_investi`/`taux_interet`/`duree_mois`/`type_remb`/`statut` avec seulement un historique générique ("Mise à jour dossier"), sans le détail des champs modifiés. Fix : réutilise `detectChangements`/`recordHistory`/`detectTypeEvenement` (désormais **exportés** depuis `investissements.js`) pour logger un diff précis champ par champ, comme l'édition manuelle.
|
||||||
|
- `backend/fix_dates_cible.mjs` : script autonome (`node fix_dates_cible.mjs`), jamais appelé automatiquement, corrige les `date_cible > 2100-01-01` — toujours présent mais pas la source du bug de cette session.
|
||||||
|
|
||||||
|
### Nouveautés Nettoyage de données (`Settings.jsx` → section `imports`, composant `DataCleanupSection.jsx`)
|
||||||
|
1. **"Corriger les dates des prêts différés"** (`POST /investissements/fix-differe-dates`) :
|
||||||
|
- Seuil d'écart désormais **configurable** via un select dans la modale : 3 / 6 / 12 / 18 / 24 mois (défaut 24, avant en dur "2 ans"). Body `{ seuilMois }`, validé côté backend contre `[3,6,12,18,24]`.
|
||||||
|
- Régénère maintenant `simul_remboursements` via `generateSimul()` après correction (avant : échéancier laissé désynchronisé).
|
||||||
|
- Déclenche immédiatement `checkStatutsRetard()` après correction (sinon un prêt dont la date recalculée tombe dans le passé restait affiché "en_cours" jusqu'au prochain minuit).
|
||||||
|
2. **"Vérifier la cohérence de l'échéancier des prêts différés"** (nouveau, `POST /investissements/check-echeancier-differe`) — inséré juste après le précédent. Vérifie que `simul_remboursements` contient exactement 1 échéance à la date `date_premiere_echeance`/`date_cible` du prêt ; régénère sinon (`generateSimulWithReinvestissements` si réinvestissements présents, sinon `generateSimul`), log `correction_auto_echeancier`.
|
||||||
|
|
||||||
|
### Job `autoStatut.js` — statuts automatiques enrichis
|
||||||
|
- `checkStatutsRetard()` fait maintenant **les deux sens** : `en_cours→en_retard` (comme avant, log `passage_auto_retard`) ET `en_retard→en_cours` (nouveau, log `retour_auto_en_cours`) — mais uniquement si (a) `date_cible` est entre aujourd'hui et +30 ans (garde-fou anti date-encore-aberrante-mais-"future") ET (b) le dernier événement d'historique touchant le statut était bien `passage_auto_retard` (jamais d'annulation automatique d'un passage en retard décidé manuellement).
|
||||||
|
- Chaque transition génère une **notification utilisateur** (table `notifications`) : type `warning` pour le passage en retard, type `success` pour le retour en cours, avec lien direct `/investissements/:id`.
|
||||||
|
- `TRACKED_FIELDS`, `recordHistory`, `detectChangements`, `detectTypeEvenement` sont maintenant `export` depuis `investissements.js` (réutilisés par `imports.js`).
|
||||||
|
|
||||||
|
### Piège outillage — lecture de la DB SQLite en direct depuis le sandbox bash (WAL + serveur actif)
|
||||||
|
- Le fichier réel `backend/data/crowdlending.db` est en mode WAL et activement écrit par le serveur Node de l'utilisateur pendant la session. Une copie manuelle (`cp` séparé du `.db`/`.db-wal`/`.db-shm`) pendant que le serveur écrit produit des **lectures tronquées ("database disk image is malformed") ou incohérentes entre deux requêtes successives** — ce n'est PAS la preuve que les données changent réellement à chaque lecture.
|
||||||
|
- `better-sqlite3` du repo est un binaire natif **Windows** (`node_modules/better-sqlite3/build/Release/better_sqlite3.node`) → `invalid ELF header` dans le sandbox Linux. Utiliser `python3` + module `sqlite3` standard à la place, sur une copie locale dans `/tmp`.
|
||||||
|
- Pour une lecture fiable d'un état ponctuel : privilégier les preuves de haut niveau déjà présentes dans l'app (page Historique du prêt, page Notifications avec horodatage) plutôt que des requêtes SQL répétées sur une DB en cours d'écriture concurrente.
|
||||||
|
|||||||
@@ -429,6 +429,41 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// ── Migration : table des révisions de conditions de prêt (taux/date) ───
|
||||||
|
// Distincte de investissement_historique (qui est une piste d'audit générique
|
||||||
|
// auto-détectée sur tout changement de champ). Ici, on trace un événement métier
|
||||||
|
// explicite (retard projet, renégociation…) avec un motif obligatoire, et qui
|
||||||
|
// déclenche la régénération de l'échéancier (cf. routes/investissements.js).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS investissement_revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
|
||||||
|
date_effet TEXT NOT NULL,
|
||||||
|
ancien_taux REAL,
|
||||||
|
nouveau_taux REAL,
|
||||||
|
ancienne_date_cible TEXT,
|
||||||
|
nouvelle_date_cible TEXT,
|
||||||
|
motif TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_revisions_inv ON investissement_revisions(investissement_id)');
|
||||||
|
|
||||||
|
// ── Migration : traçage de la durée lors d'une révision de date cible ────
|
||||||
|
// nouvelle_date_cible ne suffisait pas à elle seule à modifier l'échéancier généré
|
||||||
|
// (generateSimul se base sur duree_mois, pas sur date_cible). Quand une révision change
|
||||||
|
// la date cible, duree_mois doit être recalculé en conséquence — on trace l'ancienne et
|
||||||
|
// la nouvelle valeur ici pour permettre un rollback fidèle (DELETE /revisions/:rid).
|
||||||
|
{
|
||||||
|
const revCols = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name);
|
||||||
|
if (!revCols.includes('ancien_duree_mois')) {
|
||||||
|
db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_duree_mois INTEGER');
|
||||||
|
}
|
||||||
|
if (!revCols.includes('nouveau_duree_mois')) {
|
||||||
|
db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_duree_mois INTEGER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Migration : rôle utilisateur ─────────────────────────────────────────
|
// ── Migration : rôle utilisateur ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||||
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
import { generateSimul, generateSimulWithReinvestissements, monthsDiff } from '../utils/schedule.js';
|
||||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -49,6 +49,34 @@ export function detectTypeEvenement(changements) {
|
|||||||
return 'modification';
|
return 'modification';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Révision des conditions de prêt (taux et/ou date cible), suite à un événement
|
||||||
|
// (retard projet, renégociation…). Distinct de l'historique générique : motif obligatoire,
|
||||||
|
// table dédiée, déclenche la régénération automatique de l'échéancier futur.
|
||||||
|
const RevisionSchema = z.object({
|
||||||
|
date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
nouveau_taux: z.number().positive().optional(),
|
||||||
|
nouvelle_date_cible: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
motif: z.string().trim().min(1, 'Le motif est obligatoire'),
|
||||||
|
}).refine(d => d.nouveau_taux !== undefined || d.nouvelle_date_cible !== undefined, {
|
||||||
|
message: 'Au moins un changement (nouveau taux ou nouvelle date cible) doit être renseigné',
|
||||||
|
});
|
||||||
|
|
||||||
|
function regenererEcheancier(investissementId) {
|
||||||
|
const hasReinvest = db.prepare(
|
||||||
|
'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'
|
||||||
|
).get(investissementId);
|
||||||
|
if (hasReinvest) {
|
||||||
|
generateSimulWithReinvestissements(db, investissementId);
|
||||||
|
} else {
|
||||||
|
const inv = db.prepare(`
|
||||||
|
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
|
||||||
|
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
|
||||||
|
FROM investissements WHERE id = ?
|
||||||
|
`).get(investissementId);
|
||||||
|
generateSimul(db, inv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
investisseur_id: z.number().int().positive().optional(),
|
investisseur_id: z.number().int().positive().optional(),
|
||||||
plateforme_id: z.number().int().positive(),
|
plateforme_id: z.number().int().positive(),
|
||||||
@@ -394,6 +422,9 @@ router.get('/:id', (req, res, next) => {
|
|||||||
const historique = db.prepare(
|
const historique = db.prepare(
|
||||||
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
|
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
|
||||||
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
|
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
|
||||||
|
const revisions = db.prepare(
|
||||||
|
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
|
||||||
|
).all(req.params.id);
|
||||||
const reinvestissements = db.prepare(
|
const reinvestissements = db.prepare(
|
||||||
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
|
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
|
||||||
).all(req.params.id);
|
).all(req.params.id);
|
||||||
@@ -414,7 +445,7 @@ router.get('/:id', (req, res, next) => {
|
|||||||
WHERE is2.investissement_id = ?
|
WHERE is2.investissement_id = ?
|
||||||
ORDER BY is_global DESC, s.nom
|
ORDER BY is_global DESC, s.nom
|
||||||
`).all(req.params.id);
|
`).all(req.params.id);
|
||||||
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, reinvestissements, categories_inv, secteurs_inv });
|
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, reinvestissements, categories_inv, secteurs_inv });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -571,6 +602,109 @@ router.delete('/:id/historique/:hid', (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/investissements/:id/revisions
|
||||||
|
// Enregistre une révision des conditions de prêt (nouveau taux et/ou nouvelle date cible),
|
||||||
|
// avec motif obligatoire. Écrase taux_interet/date_cible de l'investissement, positionne
|
||||||
|
// date_debut_simul sur la date d'effet, et régénère automatiquement l'échéancier futur.
|
||||||
|
//
|
||||||
|
// Important : generateSimul() calcule l'échéancier à partir de duree_mois (pas de date_cible,
|
||||||
|
// qui n'est qu'un champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie et
|
||||||
|
// diffère de la date actuelle, duree_mois doit être recalculé en conséquence, sinon la
|
||||||
|
// nouvelle date cible ne serait que cosmétique et l'échéancier généré ne la refléterait pas.
|
||||||
|
router.post('/:id/revisions', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = RevisionSchema.parse(req.body);
|
||||||
|
const invId = Number(req.params.id);
|
||||||
|
const inv = db.prepare(`
|
||||||
|
SELECT i.* FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE i.id = ? AND inv.user_id = ?
|
||||||
|
`).get(invId, req.user.id);
|
||||||
|
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||||
|
|
||||||
|
const ancien_taux = inv.taux_interet ?? null;
|
||||||
|
const ancienne_date_cible = inv.date_cible ?? null;
|
||||||
|
const ancien_duree_mois = inv.duree_mois ?? null;
|
||||||
|
const nouveau_taux = body.nouveau_taux ?? ancien_taux;
|
||||||
|
const nouvelle_date_cible = body.nouvelle_date_cible ?? ancienne_date_cible;
|
||||||
|
|
||||||
|
// Recalcule duree_mois si la date cible change réellement, pour que l'échéancier régénéré
|
||||||
|
// atteigne effectivement cette nouvelle date (cf. convention date_cible = date_premiere_echeance
|
||||||
|
// + (duree_mois - 1) mois, utilisée côté frontend pour tous les types de prêt).
|
||||||
|
let nouveau_duree_mois = ancien_duree_mois;
|
||||||
|
if (nouvelle_date_cible !== ancienne_date_cible && inv.date_premiere_echeance) {
|
||||||
|
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let revisionId;
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO investissement_revisions
|
||||||
|
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
|
||||||
|
ancien_duree_mois, nouveau_duree_mois, motif)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
`).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
|
||||||
|
ancien_duree_mois, nouveau_duree_mois, body.motif);
|
||||||
|
revisionId = r.lastInsertRowid;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE investissements
|
||||||
|
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, invId);
|
||||||
|
|
||||||
|
regenererEcheancier(invId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
const revision = db.prepare('SELECT * FROM investissement_revisions WHERE id = ?').get(revisionId);
|
||||||
|
res.status(201).json(revision);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/investissements/:id/revisions/:rid
|
||||||
|
// Seule la révision la plus récente peut être supprimée (rollback vers l'état précédent,
|
||||||
|
// puis régénération de l'échéancier). Évite les états incohérents en cas de suppression
|
||||||
|
// d'une révision intermédiaire.
|
||||||
|
router.delete('/:id/revisions/:rid', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const invId = Number(req.params.id);
|
||||||
|
const inv = db.prepare(`
|
||||||
|
SELECT i.id FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE i.id = ? AND inv.user_id = ?
|
||||||
|
`).get(invId, req.user.id);
|
||||||
|
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||||
|
|
||||||
|
const derniere = db.prepare(
|
||||||
|
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
|
||||||
|
).get(invId);
|
||||||
|
if (!derniere || derniere.id !== Number(req.params.rid)) {
|
||||||
|
throw new HttpError(400, 'Seule la révision la plus récente peut être supprimée');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE investissements
|
||||||
|
SET taux_interet = ?, date_cible = ?, duree_mois = ?,
|
||||||
|
date_debut_simul = (
|
||||||
|
SELECT date_effet FROM investissement_revisions
|
||||||
|
WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1
|
||||||
|
),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, invId, derniere.id, invId);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM investissement_revisions WHERE id = ?').run(derniere.id);
|
||||||
|
|
||||||
|
regenererEcheancier(invId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /api/investissements/:id/auto-reinvest { active: true|false }
|
// PUT /api/investissements/:id/auto-reinvest { active: true|false }
|
||||||
router.put('/:id/auto-reinvest', (req, res, next) => {
|
router.put('/:id/auto-reinvest', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function buildSchedule({ montant, taux, duree, type, freq, startDate, fin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Nombre de mois entiers entre deux dates ISO */
|
/** Nombre de mois entiers entre deux dates ISO */
|
||||||
function monthsDiff(isoA, isoB) {
|
export function monthsDiff(isoA, isoB) {
|
||||||
const a = new Date(isoA);
|
const a = new Date(isoA);
|
||||||
const b = new Date(isoB);
|
const b = new Date(isoB);
|
||||||
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
||||||
|
|||||||
@@ -160,6 +160,17 @@ export default function InvestissementDetail() {
|
|||||||
const [confirmingRembDelete, setConfirmingRembDelete] = useState(false);
|
const [confirmingRembDelete, setConfirmingRembDelete] = useState(false);
|
||||||
const [confirmingInvDelete, setConfirmingInvDelete] = useState(false);
|
const [confirmingInvDelete, setConfirmingInvDelete] = useState(false);
|
||||||
const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer
|
const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer
|
||||||
|
// Modal révision des conditions de prêt (taux/date suite à un événement)
|
||||||
|
const [revisionModalOpen, setRevisionModalOpen] = useState(false);
|
||||||
|
const [revisionForm, setRevisionForm] = useState({
|
||||||
|
date_effet: today(),
|
||||||
|
changeTaux: false, nouveau_taux: '',
|
||||||
|
changeDate: false, nouvelle_date_cible: '',
|
||||||
|
motif: '',
|
||||||
|
});
|
||||||
|
const [revisionErr, setRevisionErr] = useState(null);
|
||||||
|
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||||
|
const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [recalculating, setRecalculating] = useState(false);
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
// Modal réinvestissement
|
// Modal réinvestissement
|
||||||
@@ -611,6 +622,56 @@ export default function InvestissementDetail() {
|
|||||||
} catch (e) { /* silencieux */ }
|
} catch (e) { /* silencieux */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRevisionModal = () => {
|
||||||
|
setRevisionForm({
|
||||||
|
date_effet: today(),
|
||||||
|
changeTaux: false, nouveau_taux: '',
|
||||||
|
changeDate: false, nouvelle_date_cible: '',
|
||||||
|
motif: '',
|
||||||
|
});
|
||||||
|
setRevisionErr(null);
|
||||||
|
setRevisionModalOpen(true);
|
||||||
|
};
|
||||||
|
const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionErr(null); };
|
||||||
|
|
||||||
|
const submitRevision = async (e) => {
|
||||||
|
e?.preventDefault?.();
|
||||||
|
setRevisionErr(null);
|
||||||
|
if (!revisionForm.date_effet) { setRevisionErr("La date d'effet est requise."); return; }
|
||||||
|
if (!revisionForm.motif.trim()) { setRevisionErr('Le motif est obligatoire.'); return; }
|
||||||
|
if (!revisionForm.changeTaux && !revisionForm.changeDate) {
|
||||||
|
setRevisionErr('Cochez au moins « Modifier le taux » ou « Modifier la date cible ».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (revisionForm.changeTaux && revisionForm.nouveau_taux === '') {
|
||||||
|
setRevisionErr('Saisissez le nouveau taux annuel.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (revisionForm.changeDate && !revisionForm.nouvelle_date_cible) {
|
||||||
|
setRevisionErr('Saisissez la nouvelle date cible.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRevisionSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/investissements/${id}/revisions`, {
|
||||||
|
date_effet: revisionForm.date_effet,
|
||||||
|
nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined,
|
||||||
|
nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined,
|
||||||
|
motif: revisionForm.motif.trim(),
|
||||||
|
});
|
||||||
|
setRevisionModalOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (e) { setRevisionErr(e.message); } finally { setRevisionSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRevision = async (rid) => {
|
||||||
|
try {
|
||||||
|
await api.del(`/investissements/${id}/revisions/${rid}`);
|
||||||
|
setConfirmingRevisionDelete(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) { setConfirmingRevisionDelete(null); }
|
||||||
|
};
|
||||||
|
|
||||||
const openReinvModal = (tab = 'manuel') => {
|
const openReinvModal = (tab = 'manuel') => {
|
||||||
setReinvForm({ montant: '', date_reinvestissement: today(), note: '' });
|
setReinvForm({ montant: '', date_reinvestissement: today(), note: '' });
|
||||||
setReinvErr(null);
|
setReinvErr(null);
|
||||||
@@ -658,6 +719,11 @@ export default function InvestissementDetail() {
|
|||||||
const simul = inv.simul || [];
|
const simul = inv.simul || [];
|
||||||
const todayStr = today();
|
const todayStr = today();
|
||||||
const historique = inv.historique || [];
|
const historique = inv.historique || [];
|
||||||
|
const revisions = inv.revisions || [];
|
||||||
|
// Dernière révision ayant effectivement changé le taux / la date cible — pour l'affichage
|
||||||
|
// "ancienne valeur barrée → nouvelle valeur" dans Informations du projet.
|
||||||
|
const lastTauxRevision = [...revisions].reverse().find(r => String(r.ancien_taux) !== String(r.nouveau_taux));
|
||||||
|
const lastDateCibleRevision = [...revisions].reverse().find(r => String(r.ancienne_date_cible) !== String(r.nouvelle_date_cible));
|
||||||
const reinvs = inv.reinvestissements || [];
|
const reinvs = inv.reinvestissements || [];
|
||||||
// Capital total = montant initial + réinvestissements
|
// Capital total = montant initial + réinvestissements
|
||||||
const capitalTotal = inv.capital_total ?? inv.montant_investi;
|
const capitalTotal = inv.capital_total ?? inv.montant_investi;
|
||||||
@@ -670,6 +736,32 @@ export default function InvestissementDetail() {
|
|||||||
const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
|
const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
|
||||||
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0);
|
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0);
|
||||||
|
|
||||||
|
// Taux annuel implicite calculé à partir des intérêts réellement versés à chaque échéance —
|
||||||
|
// utile pour repérer une revalorisation de taux non annoncée par la plateforme.
|
||||||
|
// Base = capital restant dû juste avant le remboursement (montant initial + réinvestissements
|
||||||
|
// survenus avant ou à cette date, moins le capital déjà remboursé aux échéances précédentes).
|
||||||
|
// Non pertinent pour un prêt différé (un seul versement, pas de série à comparer).
|
||||||
|
const tauxImpliciteApplicable = inv.freq_interets === 'mensuel' || inv.freq_interets === 'trimestriel';
|
||||||
|
const tauxImpliciteByRembId = new Map();
|
||||||
|
if (tauxImpliciteApplicable) {
|
||||||
|
const rembTries = [...remb].sort((a, b) => a.date_remb.localeCompare(b.date_remb));
|
||||||
|
const reinvTries = [...reinvs].sort((a, b) => a.date_reinvestissement.localeCompare(b.date_reinvestissement));
|
||||||
|
const freqStep = inv.freq_interets === 'trimestriel' ? 3 : 1;
|
||||||
|
let reinvIdx = 0;
|
||||||
|
let capitalCourant = inv.montant_investi;
|
||||||
|
for (const r of rembTries) {
|
||||||
|
while (reinvIdx < reinvTries.length && reinvTries[reinvIdx].date_reinvestissement <= r.date_remb) {
|
||||||
|
capitalCourant += reinvTries[reinvIdx].montant;
|
||||||
|
reinvIdx++;
|
||||||
|
}
|
||||||
|
const base = capitalCourant;
|
||||||
|
const tauxBrut = base > 0 ? (r.interets_bruts || 0) / base * (12 / freqStep) * 100 : null;
|
||||||
|
const tauxNet = base > 0 ? (r.interets_nets || 0) / base * (12 / freqStep) * 100 : null;
|
||||||
|
tauxImpliciteByRembId.set(r.id, { base, tauxBrut, tauxNet });
|
||||||
|
capitalCourant -= (r.capital || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rapprochement projections ↔ remboursements réels
|
// Rapprochement projections ↔ remboursements réels
|
||||||
// Priorité : date exacte, sinon même mois (YYYY-MM)
|
// Priorité : date exacte, sinon même mois (YYYY-MM)
|
||||||
// Si tout le capital est remboursé (capRestant = 0), fallback sur le dernier remboursement
|
// Si tout le capital est remboursé (capRestant = 0), fallback sur le dernier remboursement
|
||||||
@@ -757,6 +849,15 @@ export default function InvestissementDetail() {
|
|||||||
notes: h.notes,
|
notes: h.notes,
|
||||||
created_at: h.created_at,
|
created_at: h.created_at,
|
||||||
})),
|
})),
|
||||||
|
revisions: (inv.revisions || []).map(r => ({
|
||||||
|
date_effet: r.date_effet,
|
||||||
|
ancien_taux: r.ancien_taux,
|
||||||
|
nouveau_taux: r.nouveau_taux,
|
||||||
|
ancienne_date_cible: r.ancienne_date_cible,
|
||||||
|
nouvelle_date_cible: r.nouvelle_date_cible,
|
||||||
|
motif: r.motif,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -935,7 +1036,21 @@ export default function InvestissementDetail() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Taux annuel</div>
|
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Taux annuel</div>
|
||||||
<div>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</div>
|
{lastTauxRevision ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ textDecoration: 'line-through', opacity: 0.6 }}>{fmtPct(lastTauxRevision.ancien_taux)}</span>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>→</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</span>
|
||||||
|
<span title={lastTauxRevision.motif} style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: '#a78bfa',
|
||||||
|
background: 'rgba(167,139,250,0.15)', borderRadius: 4, padding: '1px 6px',
|
||||||
|
}}>
|
||||||
|
Révisé le {fmtDate(lastTauxRevision.date_effet)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Durée</div>
|
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Durée</div>
|
||||||
@@ -951,7 +1066,21 @@ export default function InvestissementDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date cible contractuelle</div>
|
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date cible contractuelle</div>
|
||||||
<div>{fmtDate(inv.date_cible) || '—'}</div>
|
{lastDateCibleRevision ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ textDecoration: 'line-through', opacity: 0.6 }}>{fmtDate(lastDateCibleRevision.ancienne_date_cible) || '—'}</span>
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>→</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{fmtDate(inv.date_cible) || '—'}</span>
|
||||||
|
<span title={lastDateCibleRevision.motif} style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: '#a78bfa',
|
||||||
|
background: 'rgba(167,139,250,0.15)', borderRadius: 4, padding: '1px 6px',
|
||||||
|
}}>
|
||||||
|
Révisé le {fmtDate(lastDateCibleRevision.date_effet)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>{fmtDate(inv.date_cible) || '—'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{inv.notes && (
|
{inv.notes && (
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
@@ -969,6 +1098,107 @@ export default function InvestissementDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Révisions des conditions de prêt ──────────────────────── */}
|
||||||
|
{revisions.length > 0 && (
|
||||||
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: 16 }}>Révisions du prêt</h3>
|
||||||
|
<div style={{ position: 'relative', paddingLeft: 28 }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 9, top: 0, bottom: 0,
|
||||||
|
width: 2, background: 'var(--border, rgba(255,255,255,.1))',
|
||||||
|
}} />
|
||||||
|
{[...revisions].reverse().map((rev, idx) => {
|
||||||
|
const isLast = idx === 0; // dernière révision (liste inversée) = seule supprimable
|
||||||
|
const isConfirmingDelete = confirmingRevisionDelete === rev.id;
|
||||||
|
const tauxChange = String(rev.ancien_taux) !== String(rev.nouveau_taux);
|
||||||
|
const dateChange = String(rev.ancienne_date_cible) !== String(rev.nouvelle_date_cible);
|
||||||
|
return (
|
||||||
|
<div key={rev.id} style={{ position: 'relative', marginBottom: idx < revisions.length - 1 ? 16 : 0 }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: -24, top: 14,
|
||||||
|
width: 10, height: 10, borderRadius: '50%',
|
||||||
|
background: '#a78bfa',
|
||||||
|
border: '2px solid var(--bg-card, #1e293b)',
|
||||||
|
zIndex: 1,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(167,139,250,0.07)',
|
||||||
|
border: isConfirmingDelete ? '1px solid rgba(239,68,68,0.5)' : '1px solid rgba(167,139,250,0.3)',
|
||||||
|
borderRadius: 8, padding: '10px 14px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 13, color: '#a78bfa' }}>⟳ Révision des conditions</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(rev.date_effet)}</span>
|
||||||
|
{isLast && !isConfirmingDelete && (
|
||||||
|
<button
|
||||||
|
title="Supprimer cette révision (rollback)"
|
||||||
|
onClick={() => setConfirmingRevisionDelete(rev.id)}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto', background: 'transparent', border: 'none',
|
||||||
|
cursor: 'pointer', padding: '2px 6px', borderRadius: 4,
|
||||||
|
color: 'var(--text-muted)', fontSize: 14, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConfirmingDelete && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '8px 12px', marginBottom: 10,
|
||||||
|
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>⚠️</span>
|
||||||
|
<span style={{ fontSize: 12, flex: 1, color: 'var(--text)', lineHeight: 1.4 }}>
|
||||||
|
Annuler cette révision ?<br />
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
|
||||||
|
Les conditions reviendront à l'état précédent et l'échéancier sera régénéré.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setConfirmingRevisionDelete(null)} style={{ fontSize: 12, padding: '4px 10px' }}>Annuler</button>
|
||||||
|
<button className="danger" onClick={() => deleteRevision(rev.id)} style={{ fontSize: 12, padding: '4px 10px' }}>Confirmer</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tauxChange && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Taux annuel</span>
|
||||||
|
<span style={{ background: 'rgba(239,68,68,0.12)', color: '#f87171', borderRadius: 4, padding: '1px 6px', textDecoration: 'line-through', opacity: 0.8 }}>
|
||||||
|
{rev.ancien_taux != null ? fmtPct(rev.ancien_taux) : '—'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>→</span>
|
||||||
|
<span style={{ background: 'rgba(34,197,94,0.12)', color: '#4ade80', borderRadius: 4, padding: '1px 6px' }}>
|
||||||
|
{rev.nouveau_taux != null ? fmtPct(rev.nouveau_taux) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dateChange && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Date cible</span>
|
||||||
|
<span style={{ background: 'rgba(239,68,68,0.12)', color: '#f87171', borderRadius: 4, padding: '1px 6px', textDecoration: 'line-through', opacity: 0.8 }}>
|
||||||
|
{fmtDate(rev.ancienne_date_cible) || '—'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>→</span>
|
||||||
|
<span style={{ background: 'rgba(34,197,94,0.12)', color: '#4ade80', borderRadius: 4, padding: '1px 6px' }}>
|
||||||
|
{fmtDate(rev.nouvelle_date_cible) || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
|
||||||
|
<span className="text-muted">Motif : </span>{rev.motif}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Remboursements enregistrés ────────────────────────────── */}
|
{/* ── Remboursements enregistrés ────────────────────────────── */}
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
@@ -1036,7 +1266,17 @@ export default function InvestissementDetail() {
|
|||||||
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
|
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
|
||||||
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
|
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
|
||||||
)}
|
)}
|
||||||
<td className="num">{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}</td>
|
<td
|
||||||
|
className={tauxImpliciteApplicable ? 'num cell-tooltip' : 'num'}
|
||||||
|
data-tooltip={(() => {
|
||||||
|
if (!tauxImpliciteApplicable) return undefined;
|
||||||
|
const ti = tauxImpliciteByRembId.get(r.id);
|
||||||
|
if (!ti || !(ti.base > 0)) return 'Taux implicite non calculable (capital restant nul ou inconnu)';
|
||||||
|
return `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`;
|
||||||
|
})()}
|
||||||
|
>
|
||||||
|
{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}
|
||||||
|
</td>
|
||||||
<td className="num">{fmtEUR(r.cashback)}</td>
|
<td className="num">{fmtEUR(r.cashback)}</td>
|
||||||
<td className="num cell-tooltip"
|
<td className="num cell-tooltip"
|
||||||
data-tooltip={`Prélèvements sociaux : ${fmtEUR(r.prelev_sociaux)}\nImpôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}`}>
|
data-tooltip={`Prélèvements sociaux : ${fmtEUR(r.prelev_sociaux)}\nImpôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}`}>
|
||||||
@@ -1468,6 +1708,97 @@ export default function InvestissementDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Modal révision des conditions de prêt ─────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={revisionModalOpen}
|
||||||
|
title="Réviser les conditions du prêt"
|
||||||
|
onClose={closeRevisionModal}
|
||||||
|
width={520}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
|
||||||
|
<button onClick={submitRevision} disabled={revisionSaving}>
|
||||||
|
{revisionSaving ? 'Enregistrement…' : 'Enregistrer la révision'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
||||||
|
À utiliser lorsqu'un événement (retard projet, renégociation…) modifie le taux et/ou la date cible
|
||||||
|
du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submitRevision}>
|
||||||
|
<label>Date d'effet</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={revisionForm.date_effet}
|
||||||
|
onChange={e => setRevisionForm(f => ({ ...f, date_effet: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label style={{
|
||||||
|
marginTop: 12, display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
cursor: 'pointer', userSelect: 'none', fontWeight: 400,
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
checked={revisionForm.changeTaux}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setRevisionForm(f => ({ ...f, changeTaux: checked, nouveau_taux: checked ? f.nouveau_taux : '' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Modifier le taux annuel <span className="text-muted">— actuel : {inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</span>
|
||||||
|
</label>
|
||||||
|
{revisionForm.changeTaux && (
|
||||||
|
<input
|
||||||
|
type="number" step="0.01" min="0" required
|
||||||
|
placeholder="Nouveau taux (%)"
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
value={revisionForm.nouveau_taux}
|
||||||
|
onChange={e => setRevisionForm(f => ({ ...f, nouveau_taux: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<label style={{
|
||||||
|
marginTop: 12, display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
cursor: 'pointer', userSelect: 'none', fontWeight: 400,
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
checked={revisionForm.changeDate}
|
||||||
|
onChange={e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setRevisionForm(f => ({ ...f, changeDate: checked, nouvelle_date_cible: checked ? f.nouvelle_date_cible : '' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Modifier la date cible <span className="text-muted">— actuelle : {fmtDate(inv.date_cible) || '—'}</span>
|
||||||
|
</label>
|
||||||
|
{revisionForm.changeDate && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="date" required
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
value={revisionForm.nouvelle_date_cible}
|
||||||
|
onChange={e => setRevisionForm(f => ({ ...f, nouvelle_date_cible: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
|
||||||
|
La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label style={{ marginTop: 10, display: 'block' }}>Motif de la révision</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ex : retard du projet, renégociation à la baisse suite à difficultés de l'émetteur…"
|
||||||
|
value={revisionForm.motif}
|
||||||
|
onChange={e => setRevisionForm(f => ({ ...f, motif: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{revisionErr && <div className="error" style={{ marginTop: 10 }}>{revisionErr}</div>}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* ── Modal édition ─────────────────────────────────────────── */}
|
{/* ── Modal édition ─────────────────────────────────────────── */}
|
||||||
<Modal
|
<Modal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -1662,6 +1993,12 @@ export default function InvestissementDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<label>Taux annuel (%)</label>
|
<label>Taux annuel (%)</label>
|
||||||
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
|
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
|
||||||
|
{lastTauxRevision && (
|
||||||
|
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
|
||||||
|
⚠ Taux révisé le {fmtDate(lastTauxRevision.date_effet)}. Une modification ici ne sera pas tracée
|
||||||
|
comme une révision — utilisez « Réviser les conditions » pour un changement lié à un événement.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Montant investi (€) *</label>
|
<label>Montant investi (€) *</label>
|
||||||
@@ -1762,6 +2099,12 @@ export default function InvestissementDetail() {
|
|||||||
setForm({ ...form, date_cible: e.target.value });
|
setForm({ ...form, date_cible: e.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{lastDateCibleRevision && (
|
||||||
|
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
|
||||||
|
⚠ Date cible révisée le {fmtDate(lastDateCibleRevision.date_effet)}. Une modification ici ne sera pas tracée
|
||||||
|
comme une révision — utilisez « Réviser les conditions » pour un changement lié à un événement.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Champ restructuration : visible uniquement si des remboursements existent
|
{/* Champ restructuration : visible uniquement si des remboursements existent
|
||||||
ET que le type de prêt a été modifié (ou qu'une date était déjà définie) */}
|
ET que le type de prêt a été modifié (ou qu'une date était déjà définie) */}
|
||||||
@@ -2410,6 +2753,7 @@ export default function InvestissementDetail() {
|
|||||||
{[
|
{[
|
||||||
{ 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); openEdit(); } },
|
{ 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); openEdit(); } },
|
||||||
{ label: 'Réinvestir', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setCardMenu(null); openReinvModal(); } },
|
{ label: 'Réinvestir', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setCardMenu(null); openReinvModal(); } },
|
||||||
|
{ label: 'Réviser les conditions', icon: <><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></>, action: () => { setCardMenu(null); openRevisionModal(); } },
|
||||||
{ 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); exportDossier(); } },
|
{ 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); exportDossier(); } },
|
||||||
].map(({ label, icon, action }) => (
|
].map(({ label, icon, action }) => (
|
||||||
<button key={label}
|
<button key={label}
|
||||||
|
|||||||
Reference in New Issue
Block a user