Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7bbe5dee6 | ||
|
|
44b8e5eda0 | ||
|
|
7519b7507f |
No files matched your search
@@ -8,6 +8,10 @@ build/
|
||||
*/dist/
|
||||
*/build/
|
||||
|
||||
# Résidus Vite (config transpilée temporairement lors de vite build/dev)
|
||||
vite.config.js.timestamp-*.mjs
|
||||
*/vite.config.js.timestamp-*.mjs
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
Generated
+25
-123
@@ -24,7 +24,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -666,15 +666,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -876,9 +867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -900,15 +891,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -1024,19 +1015,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1079,15 +1057,6 @@
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1189,18 +1158,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1499,9 +1456,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
|
||||
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1589,15 +1546,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -1944,9 +1892,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2222,31 +2170,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz",
|
||||
"integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-finished": "~2.4.1",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -3053,18 +2993,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -3362,24 +3290,6 @@
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
@@ -3401,19 +3311,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"name": "@e965/xlsx",
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import xlsx from 'xlsx';
|
||||
// @e965/xlsx (fork npm de SheetJS, cf. project_npm_audit_dependances.md) expose un build ESM qui ne détecte pas
|
||||
// automatiquement fs.readFileSync/writeFileSync sous Node (contrairement à l'ancien 'xlsx' CJS) : set_fs est requis.
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
xlsx.set_fs(fs);
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import xlsx from 'xlsx';
|
||||
// @e965/xlsx (fork npm de SheetJS, cf. project_npm_audit_dependances.md) expose un build ESM qui ne détecte pas
|
||||
// automatiquement fs.readFileSync/writeFileSync sous Node (contrairement à l'ancien 'xlsx' CJS) : set_fs est requis.
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
xlsx.set_fs(fs);
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
|
||||
Generated
+135
-222
@@ -12,7 +12,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -20,13 +20,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -35,9 +35,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -45,21 +45,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -76,14 +76,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
|
||||
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@babel/types": "^7.29.8",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -93,14 +93,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -110,9 +110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -120,29 +120,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -162,9 +162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -172,9 +172,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -182,9 +182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -192,27 +192,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -263,33 +263,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
|
||||
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.8",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.8",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -297,14 +297,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -752,9 +752,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -1210,15 +1210,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
@@ -1229,9 +1220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.25",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
|
||||
"integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==",
|
||||
"version": "2.11.20",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
|
||||
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1242,9 +1233,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1262,11 +1253,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"electron-to-chromium": "^1.5.402",
|
||||
"node-releases": "^2.0.53",
|
||||
"update-browserslist-db": "^1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -1276,9 +1267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001791",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
||||
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1316,28 +1307,6 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1357,18 +1326,6 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
@@ -1397,18 +1354,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
||||
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.349",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
|
||||
"version": "1.5.416",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz",
|
||||
"integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1478,15 +1435,6 @@
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1621,9 +1569,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1640,11 +1588,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
@@ -1677,9 +1628,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1697,7 +1648,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1751,12 +1702,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
"@remix-run/router": "1.23.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -1766,13 +1717,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -1873,18 +1824,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
@@ -1915,9 +1854,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2014,38 +1953,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"name": "@e965/xlsx",
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import Modal from './Modal.jsx';
|
||||
import InvSelect from './InvSelect.jsx';
|
||||
import CountrySelect from './CountrySelect.jsx';
|
||||
import { addMonthsFE, addMonthsFinDeMoisFE, lastDayOfMonthFE, dayOfMonth } from '../utils/dateEcheances.js';
|
||||
|
||||
/**
|
||||
* InvestissementFormModal — formulaire partagé de création/édition d'un investissement.
|
||||
*
|
||||
* Utilisé par Investissements.jsx (création + édition depuis la liste) et
|
||||
* InvestissementDetail.jsx (édition depuis la fiche). Comme RembFormModal, ce composant
|
||||
* est volontairement "bête" côté état : le parent garde la propriété du form (state,
|
||||
* effets de bord au changement de plateforme comme le préremplissage des catégories/secteurs
|
||||
* ou la résolution du détenteur, chargement des comptes du détenteur…). Ce composant se
|
||||
* contente des cascades de champs qui ne dépendent que du form lui-même (dates, durée, type
|
||||
* de prêt, méthode de remboursement) et du rendu JSX.
|
||||
*
|
||||
* Depuis le 29/08/26, l'affichage se fait en 3 étapes (assistant) plutôt qu'en un seul
|
||||
* écran : (1) Informations générales, (2) Données financières et échéancier,
|
||||
* (3) Catégories, secteurs, autres. L'étape courante (`step`) est un état interne au
|
||||
* composant, non exposé au parent, et se réinitialise à 1 à chaque ouverture de la modale
|
||||
* (transition `open` false → true). "Suivant" valide les champs obligatoires (natifs
|
||||
* `required`) de l'étape affichée via `reportValidity()` avant d'avancer ; "Précédent" ne
|
||||
* valide rien. Le bouton Supprimer (édition) reste visible sur les 3 étapes.
|
||||
*
|
||||
* Props :
|
||||
* open, onClose — état d'ouverture
|
||||
* editingId — id en édition, falsy si création (contrôle le titre,
|
||||
* l'affichage du champ Statut, et le libellé du bouton)
|
||||
* form — objet form courant (voir `empty`/`emptyForm` des pages appelantes)
|
||||
* onFieldChange(key, value) — pour les champs simples, sans effet de bord
|
||||
* onFormPatch(patchOrUpdater) — pour les cascades (objet complet à fusionner, ou
|
||||
* fonction (prev) => next pour les cas sensibles au Strict Mode)
|
||||
* err — message d'erreur à afficher
|
||||
* onSubmit — handler de soumission
|
||||
* onDelete, confirmingDelete, setConfirmingDelete
|
||||
* — flux de suppression optionnel (affiché si onDelete fourni)
|
||||
* plats — liste des plateformes
|
||||
* multiDetenteur — bool, affiche le détenteur de chaque plateforme dans le select
|
||||
* investisseurs — liste des détenteurs (select Détenteur, avec optgroups Famille/Entreprises)
|
||||
* comptesInvestisseur — comptes du détenteur courant (select Compte de réception, étape 2)
|
||||
* onPlateformeChange(platformId) — délégué au parent : résolution du détenteur, méthode héritée,
|
||||
* reset du compte, préremplissage catégories/secteurs
|
||||
* categoriesInv, secteursInv — listes pour les sélecteurs Catégories/Secteurs d'investissement
|
||||
* onCategoryAdded, onSectorAdded — callbacks après création d'une catégorie/d'un secteur via InvSelect
|
||||
* tauxRevisionWarning — JSX optionnel affiché sous le champ Taux annuel (étape 2)
|
||||
* dateCibleRevisionWarning — JSX optionnel affiché sous le champ Date cible (étape 2)
|
||||
* restructurationBlock — JSX optionnel affiché après le champ Date cible, étape 2 (pleine largeur)
|
||||
* width — largeur de la modale (défaut 720)
|
||||
*/
|
||||
export default function InvestissementFormModal({
|
||||
open, onClose, editingId,
|
||||
form, onFieldChange, onFormPatch,
|
||||
err, onSubmit, onDelete, confirmingDelete, setConfirmingDelete,
|
||||
plats = [], multiDetenteur = false,
|
||||
investisseurs = [],
|
||||
comptesInvestisseur = [],
|
||||
onPlateformeChange,
|
||||
categoriesInv = [], secteursInv = [],
|
||||
onCategoryAdded, onSectorAdded,
|
||||
tauxRevisionWarning = null,
|
||||
dateCibleRevisionWarning = null,
|
||||
restructurationBlock = null,
|
||||
width = 720,
|
||||
}) {
|
||||
// Vestige côté données : mémorise la dernière souscription valide saisie.
|
||||
// N'est lu nulle part dans le code actuel — conservé tel quel (écriture sans effet)
|
||||
// pour ne rien changer au comportement observable pendant le regroupement des deux formulaires.
|
||||
const lastValidSouscriptionRef = useRef('');
|
||||
|
||||
const formRef = useRef(null);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// Repart de l'étape 1 à chaque (ré)ouverture de la modale, création comme édition.
|
||||
useEffect(() => {
|
||||
if (open) setStep(1);
|
||||
}, [open]);
|
||||
|
||||
const stepTitles = [
|
||||
"Informations générales de l'investissement",
|
||||
'Données financières et échéancier',
|
||||
'Catégories, secteurs, autres',
|
||||
];
|
||||
|
||||
const goNext = () => {
|
||||
if (formRef.current && !formRef.current.reportValidity()) return;
|
||||
setStep(s => Math.min(3, s + 1));
|
||||
};
|
||||
const goPrev = () => setStep(s => Math.max(1, s - 1));
|
||||
|
||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
||||
const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe')
|
||||
&& dayOfMonth(form.date_premiere_echeance) > 27;
|
||||
|
||||
const handleMethodeChange = (methode) => {
|
||||
const defaultCompte = methode === 'compte_courant'
|
||||
? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0])
|
||||
: null;
|
||||
onFormPatch({ methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') });
|
||||
};
|
||||
|
||||
const handleCompteChange = (compteId) => {
|
||||
const c = comptesInvestisseur.find(c => String(c.id) === compteId);
|
||||
onFormPatch({ compte_id: compteId, nom_compte_courant: c?.nom || '' });
|
||||
};
|
||||
|
||||
const handleTypeRembChange = (t) => {
|
||||
const next = {
|
||||
...form, type_remb: t,
|
||||
freq_interets: t === 'differe' ? 'in_fine' : (form.freq_interets === 'in_fine' ? 'mensuel' : form.freq_interets),
|
||||
};
|
||||
if (t === 'differe') {
|
||||
// Recalculer la date d'échéance depuis souscription+durée (prioritaire sur l'ancienne valeur)
|
||||
if (form.date_souscription && form.duree_mois) {
|
||||
const dpe = addMonthsFE(form.date_souscription, 1);
|
||||
const cible = addMonthsFE(dpe, Number(form.duree_mois) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
} else if (form.date_premiere_echeance) {
|
||||
next.date_cible = form.date_premiere_echeance;
|
||||
}
|
||||
} else {
|
||||
if (form.date_souscription) {
|
||||
next.date_premiere_echeance = addMonthsFE(form.date_souscription, 1);
|
||||
if (next.date_premiere_echeance && form.duree_mois) {
|
||||
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(form.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
onFormPatch(next);
|
||||
};
|
||||
|
||||
const handleDureeChange = (duree) => {
|
||||
const next = { ...form, duree_mois: duree };
|
||||
if (duree) {
|
||||
if (form.type_remb === 'differe' && form.date_souscription) {
|
||||
const tempDpe = addMonthsFE(form.date_souscription, 1);
|
||||
const cible = addMonthsFE(tempDpe, Number(duree) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
} else if (form.date_premiere_echeance) {
|
||||
next.date_cible = addMonthsFE(form.date_premiere_echeance, Number(duree) - 1);
|
||||
}
|
||||
}
|
||||
onFormPatch(next);
|
||||
};
|
||||
|
||||
const handleSouscriptionChange = (souscription) => {
|
||||
onFormPatch(prev => {
|
||||
const next = { ...prev, date_souscription: souscription };
|
||||
if (!souscription || souscription.length !== 10) return next;
|
||||
if (prev.type_remb === 'differe') {
|
||||
if (prev.duree_mois) {
|
||||
const tempDpe = addMonthsFE(souscription, 1);
|
||||
const cible = addMonthsFE(tempDpe, Number(prev.duree_mois) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
}
|
||||
} else {
|
||||
// Pour in_fine / amortissable : recalcul systématique de premiere_echeance
|
||||
// (cohérent avec le comportement de "differe" et évite les faux négatifs
|
||||
// du test isAutoCalc quand premiere_echeance ne vaut pas exactement souscription+1m)
|
||||
next.date_premiere_echeance = addMonthsFE(souscription, 1);
|
||||
if (next.date_premiere_echeance && prev.duree_mois) {
|
||||
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(prev.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (souscription && souscription.length === 10) {
|
||||
lastValidSouscriptionRef.current = souscription;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDpeChange = (dpe) => {
|
||||
const next = { ...form, date_premiere_echeance: dpe };
|
||||
// Si le jour passe à ≤ 27 on désactive fin-de-mois
|
||||
if (dayOfMonth(dpe) <= 27) next.echeance_fin_de_mois = false;
|
||||
if (form.type_remb === 'differe') {
|
||||
next.date_cible = dpe;
|
||||
} else if (dpe && form.duree_mois) {
|
||||
next.date_cible = next.echeance_fin_de_mois
|
||||
? addMonthsFinDeMoisFE(dpe, Number(form.duree_mois) - 1)
|
||||
: addMonthsFE(dpe, Number(form.duree_mois) - 1);
|
||||
}
|
||||
onFormPatch(next);
|
||||
};
|
||||
|
||||
const handleFinDeMoisChange = (checked) => {
|
||||
onFormPatch(prev => {
|
||||
const next = { ...prev, echeance_fin_de_mois: checked };
|
||||
if (checked && prev.date_premiere_echeance) {
|
||||
const newDpe = lastDayOfMonthFE(prev.date_premiere_echeance);
|
||||
next.date_premiere_echeance = newDpe;
|
||||
if (prev.type_remb === 'differe') {
|
||||
next.date_cible = newDpe;
|
||||
} else if (newDpe && prev.duree_mois) {
|
||||
next.date_cible = addMonthsFinDeMoisFE(newDpe, Number(prev.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
|
||||
onClose={onClose}
|
||||
width={width}
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId && onDelete && (
|
||||
!confirmingDelete ? (
|
||||
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||
Supprimer définitivement ?
|
||||
</span>
|
||||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{step === 1 && <button onClick={onClose}>Annuler</button>}
|
||||
{step > 1 && <button onClick={goPrev}>Précédent</button>}
|
||||
{step < 3 && <button className="primary" onClick={goNext}>Suivant</button>}
|
||||
{step === 3 && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* ── Indicateur d'étape ── */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||
Étape {step} : {stepTitles[step - 1]}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{[1, 2, 3].map(n => (
|
||||
<div key={n} style={{
|
||||
flex: 1, height: 5, borderRadius: 4,
|
||||
background: n <= step ? 'var(--primary)' : 'var(--border)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={onSubmit}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
|
||||
{/* ══ Étape 1 : Informations générales ══ */}
|
||||
{step === 1 && (<>
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.plateforme_id} onChange={e => onPlateformeChange(e.target.value)}>
|
||||
<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>
|
||||
<div>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.investisseur_id} onChange={e => onFieldChange('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: 'span 2' }}>
|
||||
<label>Nom du projet *</label>
|
||||
<input required value={form.nom_projet} onChange={e => onFieldChange('nom_projet', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Émetteur</label>
|
||||
<input value={form.emetteur}
|
||||
onChange={e => onFieldChange('emetteur', e.target.value)}
|
||||
placeholder="Nom de l'emprunteur"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Référence</label>
|
||||
<input value={form.reference}
|
||||
onChange={e => onFieldChange('reference', e.target.value)}
|
||||
placeholder="N° de dossier, code projet…"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Pays d'exposition</label>
|
||||
<CountrySelect
|
||||
value={form.pays_exposition || 'FR'}
|
||||
onChange={code => onFieldChange('pays_exposition', code)}
|
||||
/>
|
||||
</div>
|
||||
{editingId && (
|
||||
<div>
|
||||
<label>Statut</label>
|
||||
<select value={form.statut} onChange={e => onFieldChange('statut', e.target.value)}>
|
||||
<option value="en_cours">En cours</option>
|
||||
<option value="rembourse">Remboursé</option>
|
||||
<option value="en_retard">En retard</option>
|
||||
<option value="procedure">Procédure</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{/* ══ Étape 2 : Données financières et échéancier ══ */}
|
||||
{step === 2 && (<>
|
||||
<div>
|
||||
<label>Montant investi (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.montant_investi}
|
||||
onChange={e => onFieldChange('montant_investi', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Taux annuel (%) *</label>
|
||||
<input type="number" step="0.01" required value={form.taux_interet}
|
||||
onChange={e => onFieldChange('taux_interet', e.target.value)} />
|
||||
{tauxRevisionWarning}
|
||||
</div>
|
||||
<div>
|
||||
<label>Durée (mois) *</label>
|
||||
<input type="number" min="0" required value={form.duree_mois} onChange={e => handleDureeChange(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Type de prêt</label>
|
||||
<select value={form.type_remb} onChange={e => handleTypeRembChange(e.target.value)}>
|
||||
<option value="in_fine">Prêt in fine</option>
|
||||
<option value="amortissable">Prêt amortissable</option>
|
||||
<option value="differe">Prêt différé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fréquence intérêts</label>
|
||||
<select
|
||||
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
|
||||
disabled={form.type_remb === 'differe'}
|
||||
onChange={e => onFieldChange('freq_interets', e.target.value)}
|
||||
>
|
||||
{form.type_remb === 'differe' ? (
|
||||
<option value="in_fine">In fine (unique)</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="mensuel">Mensuel</option>
|
||||
<option value="trimestriel">Trimestriel</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date souscription *</label>
|
||||
<input type="date" required value={form.date_souscription} onChange={e => handleSouscriptionChange(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>{form.type_remb === 'differe' ? "Date d'échéance (versement unique) *" : 'Date 1ère échéance *'}</label>
|
||||
<input type="date" required value={form.date_premiere_echeance} onChange={e => handleDpeChange(e.target.value)} />
|
||||
{/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */}
|
||||
{showFinDeMois && (
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
marginTop: 6, cursor: 'pointer', userSelect: 'none',
|
||||
fontSize: 'var(--fs-sm)', color: 'var(--text)',
|
||||
width: 'fit-content',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', flexShrink: 0 }}
|
||||
checked={form.echeance_fin_de_mois}
|
||||
onChange={e => handleFinDeMoisChange(e.target.checked)}
|
||||
/>
|
||||
Dernier jour du mois
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' — calculée' : ''}</label>
|
||||
<input type="date" value={form.date_cible}
|
||||
readOnly={form.type_remb === 'differe'}
|
||||
style={form.type_remb === 'differe' ? { opacity: 0.6, cursor: 'default' } : undefined}
|
||||
onChange={e => {
|
||||
if (form.type_remb === 'differe') return;
|
||||
onFieldChange('date_cible', e.target.value);
|
||||
}}
|
||||
/>
|
||||
{dateCibleRevisionWarning}
|
||||
</div>
|
||||
{platMethode && (platMethode === 'choix_investisseur' ? (
|
||||
<div>
|
||||
<label>Méthode de remboursement</label>
|
||||
<select value={form.methode_remboursement} onChange={e => handleMethodeChange(e.target.value)}>
|
||||
<option value="">— Non renseignée —</option>
|
||||
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
||||
<option value="compte_courant">Compte courant de l'investisseur</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label>Méthode de remboursement</label>
|
||||
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
|
||||
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
|
||||
</div>
|
||||
))}
|
||||
{form.methode_remboursement === 'compte_courant' && (
|
||||
<div>
|
||||
<label>Compte de réception *</label>
|
||||
{comptesInvestisseur.length > 0 ? (
|
||||
<select required value={form.compte_id} onChange={e => handleCompteChange(e.target.value)}>
|
||||
<option value="">— Choisir un compte —</option>
|
||||
{comptesInvestisseur.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{restructurationBlock}
|
||||
</>)}
|
||||
|
||||
{/* ══ Étape 3 : Catégories, secteurs, autres ══ */}
|
||||
{step === 3 && (<>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Notes</label>
|
||||
<textarea rows={3} value={form.notes}
|
||||
onChange={e => onFieldChange('notes', e.target.value)}
|
||||
placeholder="Informations complémentaires…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Catégories d'investissement */}
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<label>Catégories d'investissement</label>
|
||||
<InvSelect
|
||||
items={categoriesInv}
|
||||
selected={form.categories_inv_ids || []}
|
||||
onChange={ids => onFieldChange('categories_inv_ids', ids)}
|
||||
addApiPath="/categories-inv"
|
||||
onItemAdded={onCategoryAdded}
|
||||
emptyLabel="Aucune catégorie d'investissement"
|
||||
addLabel="Ajouter une catégorie d'investissement"
|
||||
inputPlaceholder="Nom de la catégorie…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secteurs d'investissement */}
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<label>Secteurs d'investissement</label>
|
||||
<InvSelect
|
||||
items={secteursInv}
|
||||
selected={form.secteurs_inv_ids || []}
|
||||
onChange={ids => onFieldChange('secteurs_inv_ids', ids)}
|
||||
addApiPath="/secteurs-inv"
|
||||
onItemAdded={onSectorAdded}
|
||||
emptyLabel="Aucun secteur d'investissement"
|
||||
addLabel="Ajouter un secteur d'investissement"
|
||||
inputPlaceholder="Nom du secteur…"
|
||||
/>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import PageIcon from '../components/PageIcon.jsx';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import InteretsMensuelsChart from '../components/InteretsMensuelsChart.jsx';
|
||||
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
|
||||
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
@@ -182,7 +183,7 @@ function KpiCard({ title, value, badge, refValue, note, onClick, hint }) {
|
||||
}
|
||||
|
||||
/* ── KPI filtrés par année ── */
|
||||
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements }) {
|
||||
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs }) {
|
||||
// Montant total déjà déclaré en perte définitive (tous statuts confondus,
|
||||
// seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) —
|
||||
// affiché en complément du KPI "Capital en risque", qui les exclut déjà
|
||||
@@ -372,6 +373,139 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
||||
return gainNetTotal - deltaCeMois;
|
||||
}, [gainNetTotal, thisMonthRow]);
|
||||
|
||||
// ── XIRR Global (réel + attendu) — depuis le début, indépendant du sélecteur
|
||||
// d'année (même logique que "Gain net depuis le début" ci-dessus). Reprend
|
||||
// exactement la méthode plateformeXirr / plateformeXirrAttendu de
|
||||
// Plateformes.jsx, appliquée ici à la totalité du portefeuille dans le
|
||||
// scope courant (activeId / activeView, déjà appliqué côté API sur
|
||||
// investissements / allRembs / allSimulRembs ; allReinvests est chargé en
|
||||
// scope 'all' comme dans Plateformes.jsx, filtré ci-dessous via `ids`).
|
||||
// Voir mémoire project_xirr_attendu_theorique.
|
||||
const getRatesForYearKpi = (dateStr) => {
|
||||
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 capRembParInvGlobal = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rb of (allRembs || [])) {
|
||||
if (!rb.investissement_id) continue;
|
||||
map[rb.investissement_id] = (map[rb.investissement_id] || 0) + (rb.capital || 0);
|
||||
}
|
||||
return map;
|
||||
}, [allRembs]);
|
||||
|
||||
const reinvestCumulParInvGlobal = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rv of (allReinvests || [])) {
|
||||
map[rv.investissement_id] = (map[rv.investissement_id] || 0) + (rv.montant || 0);
|
||||
}
|
||||
return map;
|
||||
}, [allReinvests]);
|
||||
|
||||
const xirrGlobal = useMemo(() => {
|
||||
const cutoff = today();
|
||||
const invs = investissements || [];
|
||||
const ids = new Set(invs.map(r => r.id));
|
||||
const flowsBrut = [];
|
||||
const flowsNet = [];
|
||||
for (const r of invs) {
|
||||
if (!r.date_souscription) continue;
|
||||
flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
}
|
||||
for (const rv of (allReinvests || [])) {
|
||||
if (!ids.has(rv.investissement_id)) continue;
|
||||
const d = rv.date_reinvestissement?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flowsBrut.push({ amount: -(rv.montant || 0), date: d });
|
||||
flowsNet.push({ amount: -(rv.montant || 0), date: d });
|
||||
}
|
||||
for (const rb of (allRembs || [])) {
|
||||
if (!ids.has(rb.investissement_id)) continue;
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||
}
|
||||
let estime = false;
|
||||
for (const r of invs) {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
|
||||
const capRemb = capRembParInvGlobal[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
if (capRestant > 0.01) {
|
||||
estime = true;
|
||||
flowsBrut.push({ amount: capRestant, date: cutoff });
|
||||
flowsNet.push({ amount: capRestant, date: cutoff });
|
||||
}
|
||||
}
|
||||
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
|
||||
flowsBrut.sort(byDate);
|
||||
flowsNet.sort(byDate);
|
||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
||||
}, [investissements, allRembs, allReinvests, reinvestCumulParInvGlobal, capRembParInvGlobal]);
|
||||
|
||||
const xirrGlobalAttendu = useMemo(() => {
|
||||
const cutoff = today();
|
||||
const invs = investissements || [];
|
||||
const ids = new Set(invs.map(r => r.id));
|
||||
const flowsBrut = [];
|
||||
const flowsNet = [];
|
||||
for (const r of invs) {
|
||||
if (!r.date_souscription) continue;
|
||||
flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
}
|
||||
for (const rv of (allReinvests || [])) {
|
||||
if (!ids.has(rv.investissement_id)) continue;
|
||||
const d = rv.date_reinvestissement?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flowsBrut.push({ amount: -(rv.montant || 0), date: d });
|
||||
flowsNet.push({ amount: -(rv.montant || 0), date: d });
|
||||
}
|
||||
for (const rb of (allRembs || [])) {
|
||||
if (!ids.has(rb.investissement_id)) continue;
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||
}
|
||||
const futureSimulParInv = {};
|
||||
for (const sr of (allSimulRembs || [])) {
|
||||
if (!ids.has(sr.investissement_id)) continue;
|
||||
const d = sr.date_prevue?.slice(0, 10);
|
||||
if (!d || d <= cutoff) continue;
|
||||
if (!futureSimulParInv[sr.investissement_id]) futureSimulParInv[sr.investissement_id] = [];
|
||||
futureSimulParInv[sr.investissement_id].push({ date: d, capital: sr.capital_prevu || 0, interets: sr.interets_prevus || 0 });
|
||||
}
|
||||
for (const r of invs) {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
|
||||
const capRemb = capRembParInvGlobal[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
if (capRestant <= 0.01) continue;
|
||||
const echeances = futureSimulParInv[r.id];
|
||||
if (echeances && echeances.length) {
|
||||
for (const e of echeances) {
|
||||
if (e.capital <= 0 && e.interets <= 0) continue;
|
||||
const rate = getRatesForYearKpi(e.date);
|
||||
const pfu = rate ? (rate.prelev_sociaux + rate.impot_revenu) / 100 : 0.3;
|
||||
flowsBrut.push({ amount: e.capital + e.interets, date: e.date });
|
||||
flowsNet.push({ amount: e.capital + e.interets * (1 - pfu), date: e.date });
|
||||
}
|
||||
} else {
|
||||
flowsBrut.push({ amount: capRestant, date: cutoff });
|
||||
flowsNet.push({ amount: capRestant, date: cutoff });
|
||||
}
|
||||
}
|
||||
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
|
||||
flowsBrut.sort(byDate);
|
||||
flowsNet.sort(byDate);
|
||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investissements, allRembs, allReinvests, allSimulRembs, reinvestCumulParInvGlobal, capRembParInvGlobal, pfuRates]);
|
||||
|
||||
// ── Valeur affichée ──
|
||||
const getValue = (field) => {
|
||||
if (isCurrentYear) return thisMonthRow?.[field] || 0;
|
||||
@@ -511,6 +645,76 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
||||
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
|
||||
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
|
||||
/>
|
||||
{(() => {
|
||||
const rendement = netMode ? xirrGlobal.net : xirrGlobal.brut;
|
||||
const rendementAttendu = netMode ? xirrGlobalAttendu.net : xirrGlobalAttendu.brut;
|
||||
const xirrExplication =
|
||||
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
||||
`des prêts du portefeuille (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}), ` +
|
||||
`depuis le début. Chaque flux est pondéré par sa date exacte.\n\n` +
|
||||
(xirrGlobal.estime
|
||||
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé à aujourd'hui — c'est donc une estimation, pas un taux définitif.`
|
||||
: 'Tous les prêts du portefeuille sont soldés : taux définitif.');
|
||||
const xirrAttenduExplication =
|
||||
`XIRR attendu (théorique) : comme le taux de rendement interne affiché par les plateformes de crowdlending ` +
|
||||
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
|
||||
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
|
||||
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
|
||||
`"comme racheté aujourd'hui", sans compter les intérêts futurs.\n\n` +
|
||||
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
|
||||
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
|
||||
`valorisé de la même façon que dans le XIRR réel.`;
|
||||
return (
|
||||
<div className="kpi">
|
||||
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span>
|
||||
XIRR Global — {netMode ? 'Net' : 'Brut'}
|
||||
{xirrGlobal.estime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
|
||||
</span>
|
||||
<span
|
||||
className="cell-tooltip tooltip-down"
|
||||
data-tooltip={xirrExplication}
|
||||
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span
|
||||
style={{ fontSize: '1.35rem', fontWeight: 700, color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}
|
||||
>
|
||||
{rendement !== null
|
||||
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
|
||||
: 'Aucun remboursement'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
|
||||
Depuis le début
|
||||
</div>
|
||||
{rendementAttendu !== null && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 6 }}>
|
||||
<span style={{
|
||||
fontSize: '0.78em', fontWeight: 600, padding: '2px 8px', borderRadius: 20,
|
||||
background: 'rgba(124,58,237,0.10)', color: '#7c3aed', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
Attendu : {(rendementAttendu * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %
|
||||
</span>
|
||||
<span
|
||||
className="cell-tooltip tooltip-down"
|
||||
data-tooltip={xirrAttenduExplication}
|
||||
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -529,6 +733,9 @@ export default function Dashboard() {
|
||||
const [plateformes, setPlateformes] = useState([]);
|
||||
const [platLoaded, setPlatLoaded] = useState(false);
|
||||
const [investissements, setInvestissements] = useState([]);
|
||||
const [allRembs, setAllRembs] = useState([]);
|
||||
const [allReinvests, setAllReinvests] = useState([]);
|
||||
const [allSimulRembs, setAllSimulRembs] = useState([]);
|
||||
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
|
||||
|
||||
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
||||
@@ -578,6 +785,13 @@ export default function Dashboard() {
|
||||
return Promise.all([
|
||||
api.get('/dashboard', scopeParams).then(setData),
|
||||
api.get('/investissements', scopeParams).then(setInvestissements).catch(() => setInvestissements([])),
|
||||
// Nécessaires au calcul du KPI "XIRR Global" (réel + attendu), même
|
||||
// méthode que plateformeXirr / plateformeXirrAttendu dans Plateformes.jsx.
|
||||
// allReinvests en scope 'all' (comme dans Plateformes.jsx) : filtré par
|
||||
// investissement via `ids` au moment du calcul XIRR.
|
||||
api.get('/remboursements', scopeParams).then(setAllRembs).catch(() => setAllRembs([])),
|
||||
api.get('/reinvestissements', { scope: 'all' }).then(setAllReinvests).catch(() => setAllReinvests([])),
|
||||
api.get('/simul/all', scopeParams).then(setAllSimulRembs).catch(() => setAllSimulRembs([])),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -635,6 +849,7 @@ export default function Dashboard() {
|
||||
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
|
||||
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
|
||||
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
|
||||
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs}
|
||||
/>
|
||||
<YearSelectorKpi />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useUi } from '../context/UiContext.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import RembFormModal from '../components/RembFormModal.jsx';
|
||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
|
||||
@@ -18,6 +20,7 @@ const emptyForm = {
|
||||
statut: 'en_cours', reference: '', notes: '', pays_exposition: 'FR',
|
||||
echeance_fin_de_mois: false,
|
||||
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
|
||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,38 +30,6 @@ const emptyForm = {
|
||||
* - 2+ → première de la liste (pas d'historique disponible sur la page détail)
|
||||
*/
|
||||
|
||||
function addMonthsFE(isoDate, n) {
|
||||
if (!isoDate || !n) return '';
|
||||
const [y, m, d] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1 + n, d));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */
|
||||
function lastDayOfMonthFE(isoDate) {
|
||||
if (!isoDate) return isoDate;
|
||||
const [y, m] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m, 0));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute n mois et positionne sur le dernier jour du mois cible.
|
||||
* Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév).
|
||||
*/
|
||||
function addMonthsFinDeMoisFE(isoDate, n) {
|
||||
if (!isoDate || (n === undefined || n === null)) return '';
|
||||
const [y, m] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */
|
||||
function dayOfMonth(isoDate) {
|
||||
if (!isoDate || isoDate.length < 10) return 0;
|
||||
return parseInt(isoDate.slice(8, 10), 10);
|
||||
}
|
||||
|
||||
function typeLabel(type_remb, freq_interets) {
|
||||
if (type_remb === 'in_fine') return `In fine · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
|
||||
if (type_remb === 'amortissable') return `Amortissable · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
|
||||
@@ -91,6 +62,8 @@ export default function InvestissementDetail() {
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [comptesCourants, setComptesCourants] = useState([]);
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
const [categoriesInv, setCategoriesInv] = useState([]);
|
||||
const [secteursInv, setSecteursInv] = useState([]);
|
||||
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
|
||||
const [pfuRates, setPfuRates] = useState([]);
|
||||
// Modal édition investissement
|
||||
@@ -185,14 +158,18 @@ export default function InvestissementDetail() {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [data, p, cats, comptes] = await Promise.all([
|
||||
const [data, p, comptes, catInv, sectInv] = await Promise.all([
|
||||
api.get(`/investissements/${id}`),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements/comptes-courants'),
|
||||
api.get('/categories-inv'),
|
||||
api.get('/secteurs-inv'),
|
||||
]);
|
||||
setInv(data);
|
||||
setPlats(p);
|
||||
setComptesCourants(comptes);
|
||||
setCategoriesInv(catInv);
|
||||
setSecteursInv(sectInv);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -555,12 +532,42 @@ export default function InvestissementDetail() {
|
||||
nom_compte_courant: inv.nom_compte_courant || '',
|
||||
compte_id: inv.compte_id || '',
|
||||
pays_exposition: inv.pays_exposition || 'FR',
|
||||
categories_inv_ids: (inv.categories_inv || []).map(c => c.id),
|
||||
secteurs_inv_ids: (inv.secteurs_inv || []).map(s => s.id),
|
||||
});
|
||||
lastValidSouscriptionRef.current = inv.date_souscription || '';
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement héritée
|
||||
* (le détenteur, lui, reste au choix de l'utilisateur — pas d'écrasement automatique en édition)
|
||||
* et préremplit catégories/secteurs depuis la plateforme. */
|
||||
const handlePlateformeChange = (platId) => {
|
||||
const platObj = plats.find(p => String(p.id) === String(platId));
|
||||
const platMethode = platObj?.methode_remboursement;
|
||||
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
||||
if (platId) {
|
||||
Promise.all([
|
||||
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
|
||||
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
|
||||
]).then(([platCats, platSects]) => {
|
||||
setForm(f => ({ ...f,
|
||||
plateforme_id: platId,
|
||||
investisseur_id: platObj?.investisseur_id ? String(platObj.investisseur_id) : f.investisseur_id,
|
||||
methode_remboursement: newMethode, compte_id: '',
|
||||
categories_inv_ids: platCats.map(c => c.id),
|
||||
secteurs_inv_ids: platSects.map(s => s.id),
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryAdded = (cat) => setCategoriesInv(prev => [...prev, cat].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||
const handleSectorAdded = (sect) => setSecteursInv(prev => [...prev, sect].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||
|
||||
const close = () => { setModalOpen(false); setErr(null); setConfirmingInvDelete(false); };
|
||||
|
||||
const submit = async (e) => {
|
||||
@@ -572,7 +579,7 @@ export default function InvestissementDetail() {
|
||||
|
||||
// Validation date d'échéance pour les prêts différés
|
||||
if (form.type_remb === 'differe' && !form.date_premiere_echeance) {
|
||||
setEditErr("La date d'échéance est requise pour un prêt différé.");
|
||||
setErr("La date d'échéance est requise pour un prêt différé. Vérifiez que la durée et la date de souscription sont renseignées.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -600,6 +607,10 @@ export default function InvestissementDetail() {
|
||||
pays_exposition: form.pays_exposition || 'FR',
|
||||
};
|
||||
await api.put(`/investissements/${id}`, payload);
|
||||
await Promise.all([
|
||||
api.put(`/investissements/${id}/categories-inv`, { ids: form.categories_inv_ids || [] }),
|
||||
api.put(`/investissements/${id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
|
||||
]);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
@@ -2111,315 +2122,40 @@ export default function InvestissementDetail() {
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal édition ─────────────────────────────────────────── */}
|
||||
<Modal
|
||||
<InvestissementFormModal
|
||||
open={modalOpen}
|
||||
title="Modifier l'investissement"
|
||||
editingId={id}
|
||||
onClose={close}
|
||||
width={720}
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||
{/* Zone suppression — à gauche */}
|
||||
<div style={{ flex: 1 }}>
|
||||
{!confirmingInvDelete ? (
|
||||
<button className="danger" onClick={() => setConfirmingInvDelete(true)}>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||
Supprimer définitivement ?
|
||||
</span>
|
||||
<button onClick={() => setConfirmingInvDelete(false)}>Non</button>
|
||||
<button className="danger" onClick={() => { onDelete(); }}>Oui, supprimer</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions standard — à droite */}
|
||||
<button onClick={close}>Annuler</button>
|
||||
<button className="primary" onClick={submit}>Enregistrer</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
const platObj = plats.find(p => String(p.id) === String(platId));
|
||||
const platMethode = platObj?.methode_remboursement;
|
||||
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
||||
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_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>
|
||||
{(() => {
|
||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
||||
if (!platMethode) return null;
|
||||
if (platMethode === 'choix_investisseur') return (
|
||||
<div>
|
||||
<label>Méthode de remboursement</label>
|
||||
<select value={form.methode_remboursement} onChange={e => {
|
||||
const methode = e.target.value;
|
||||
const defaultCompte = methode === 'compte_courant'
|
||||
? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0])
|
||||
: null;
|
||||
setForm({ ...form, methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') });
|
||||
}}>
|
||||
<option value="">— Non renseignée —</option>
|
||||
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
||||
<option value="compte_courant">Compte courant de l'investisseur</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<label>Méthode de remboursement</label>
|
||||
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
|
||||
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{form.methode_remboursement === 'compte_courant' && (
|
||||
<div>
|
||||
<label>Compte de réception *</label>
|
||||
{comptesInvestisseur.length > 0 ? (
|
||||
<select
|
||||
required
|
||||
value={form.compte_id}
|
||||
onChange={e => {
|
||||
const c = comptesInvestisseur.find(c => String(c.id) === e.target.value);
|
||||
setForm({ ...form, compte_id: e.target.value, nom_compte_courant: c?.nom || '' });
|
||||
}}
|
||||
>
|
||||
<option value="">— Choisir un compte —</option>
|
||||
{comptesInvestisseur.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, 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: 'span 2' }}>
|
||||
<label>Nom du projet *</label>
|
||||
<input required value={form.nom_projet} onChange={e => setForm({ ...form, nom_projet: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Type de prêt</label>
|
||||
<select value={form.type_remb} onChange={e => {
|
||||
const t = e.target.value;
|
||||
const next = {
|
||||
...form,
|
||||
type_remb: t,
|
||||
freq_interets: t === 'differe' ? 'in_fine' : (form.freq_interets === 'in_fine' ? 'mensuel' : form.freq_interets),
|
||||
};
|
||||
if (t === 'differe') {
|
||||
// Recalculer la date d'échéance depuis souscription+durée (prioritaire sur l'ancienne valeur)
|
||||
if (form.date_souscription && form.duree_mois) {
|
||||
const dpe = addMonthsFE(form.date_souscription, 1);
|
||||
const cible = addMonthsFE(dpe, Number(form.duree_mois) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
} else if (form.date_premiere_echeance) {
|
||||
next.date_cible = form.date_premiere_echeance;
|
||||
}
|
||||
} else {
|
||||
if (form.date_souscription) {
|
||||
next.date_premiere_echeance = addMonthsFE(form.date_souscription, 1);
|
||||
if (next.date_premiere_echeance && form.duree_mois)
|
||||
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(form.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
}}>
|
||||
<option value="in_fine">Prêt in fine</option>
|
||||
<option value="amortissable">Prêt amortissable</option>
|
||||
<option value="differe">Prêt différé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fréquence intérêts</label>
|
||||
<select
|
||||
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
|
||||
disabled={form.type_remb === 'differe'}
|
||||
onChange={e => setForm({ ...form, freq_interets: e.target.value })}
|
||||
>
|
||||
{form.type_remb === 'differe' ? (
|
||||
<option value="in_fine">In fine (unique)</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="mensuel">Mensuel</option>
|
||||
<option value="trimestriel">Trimestriel</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Durée (mois)</label>
|
||||
<input type="number" min="0" value={form.duree_mois} onChange={e => {
|
||||
const duree = e.target.value;
|
||||
const next = { ...form, duree_mois: duree };
|
||||
if (duree) {
|
||||
if (form.type_remb === 'differe' && form.date_souscription) {
|
||||
const tempDpe = addMonthsFE(form.date_souscription, 1);
|
||||
const cible = addMonthsFE(tempDpe, Number(duree) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
} else if (form.date_premiere_echeance) {
|
||||
next.date_cible = addMonthsFE(form.date_premiere_echeance, Number(duree) - 1);
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Taux annuel (%)</label>
|
||||
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
|
||||
{lastTauxRevision && (
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
confirmingDelete={confirmingInvDelete}
|
||||
setConfirmingDelete={setConfirmingInvDelete}
|
||||
form={form}
|
||||
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
|
||||
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
|
||||
err={err}
|
||||
plats={plats}
|
||||
multiDetenteur={multiDetenteur}
|
||||
investisseurs={investisseurs}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
onPlateformeChange={handlePlateformeChange}
|
||||
categoriesInv={categoriesInv}
|
||||
secteursInv={secteursInv}
|
||||
onCategoryAdded={handleCategoryAdded}
|
||||
onSectorAdded={handleSectorAdded}
|
||||
tauxRevisionWarning={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>
|
||||
<label>Montant investi (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.montant_investi} onChange={e => setForm({ ...form, montant_investi: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Date souscription *</label>
|
||||
<input type="date" required value={form.date_souscription} onChange={e => {
|
||||
const souscription = e.target.value;
|
||||
setForm(prev => {
|
||||
const next = { ...prev, date_souscription: souscription };
|
||||
if (!souscription || souscription.length !== 10) return next;
|
||||
if (prev.type_remb === 'differe') {
|
||||
if (prev.duree_mois) {
|
||||
const tempDpe = addMonthsFE(souscription, 1);
|
||||
const cible = addMonthsFE(tempDpe, Number(prev.duree_mois) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
}
|
||||
} else {
|
||||
// Pour in_fine / amortissable : recalcul systématique de premiere_echeance
|
||||
// (cohérent avec le comportement de "differe" et évite les faux négatifs
|
||||
// du test isAutoCalc quand premiere_echeance ne vaut pas exactement souscription+1m)
|
||||
next.date_premiere_echeance = addMonthsFE(souscription, 1);
|
||||
if (next.date_premiere_echeance && prev.duree_mois)
|
||||
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(prev.duree_mois) - 1);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Mémorise la dernière souscription complète HORS setForm (évite les doubles appels Strict Mode)
|
||||
if (souscription && souscription.length === 10) {
|
||||
lastValidSouscriptionRef.current = souscription;
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Date 1ère échéance</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date_premiere_echeance}
|
||||
onChange={e => {
|
||||
const dpe = e.target.value;
|
||||
const next = { ...form, date_premiere_echeance: dpe };
|
||||
if (dayOfMonth(dpe) <= 27) next.echeance_fin_de_mois = false;
|
||||
if (form.type_remb === 'differe') {
|
||||
next.date_cible = dpe;
|
||||
} else if (dpe && form.duree_mois) {
|
||||
next.date_cible = next.echeance_fin_de_mois
|
||||
? addMonthsFinDeMoisFE(dpe, Number(form.duree_mois) - 1)
|
||||
: addMonthsFE(dpe, Number(form.duree_mois) - 1);
|
||||
}
|
||||
setForm(next);
|
||||
}}
|
||||
/>
|
||||
{/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */}
|
||||
{(form.type_remb === 'in_fine' || form.type_remb === 'differe') &&
|
||||
dayOfMonth(form.date_premiere_echeance) > 27 && (
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
marginTop: 6, cursor: 'pointer', userSelect: 'none',
|
||||
fontSize: 'var(--fs-sm)', color: 'var(--text)',
|
||||
width: 'fit-content',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', flexShrink: 0 }}
|
||||
checked={form.echeance_fin_de_mois}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
setForm(prev => {
|
||||
const next = { ...prev, echeance_fin_de_mois: checked };
|
||||
if (checked && prev.date_premiere_echeance) {
|
||||
const newDpe = lastDayOfMonthFE(prev.date_premiere_echeance);
|
||||
next.date_premiere_echeance = newDpe;
|
||||
if (prev.type_remb === 'differe') {
|
||||
next.date_cible = newDpe;
|
||||
} else if (newDpe && prev.duree_mois) {
|
||||
next.date_cible = addMonthsFinDeMoisFE(newDpe, Number(prev.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Dernier jour du mois
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' — calculée' : ''}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date_cible}
|
||||
readOnly={form.type_remb === 'differe'}
|
||||
style={form.type_remb === 'differe' ? { opacity: 0.6, cursor: 'default' } : undefined}
|
||||
onChange={e => {
|
||||
if (form.type_remb === 'differe') return;
|
||||
setForm({ ...form, date_cible: e.target.value });
|
||||
}}
|
||||
/>
|
||||
{lastDateCibleRevision && (
|
||||
dateCibleRevisionWarning={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>
|
||||
{/* 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) */}
|
||||
{remb.length > 0 && (form.type_remb !== inv.type_remb || !!inv.date_debut_simul) && (
|
||||
restructurationBlock={remb.length > 0 && (form.type_remb !== inv.type_remb || !!inv.date_debut_simul) && (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{
|
||||
borderLeft: '3px solid var(--warning, #f59e0b)',
|
||||
@@ -2465,39 +2201,8 @@ export default function InvestissementDetail() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Statut</label>
|
||||
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
|
||||
<option value="en_cours">En cours</option>
|
||||
<option value="rembourse">Remboursé</option>
|
||||
<option value="en_retard">En retard</option>
|
||||
<option value="procedure">Procédure</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Référence (ID plateforme)</label>
|
||||
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Pays d'exposition</label>
|
||||
<CountrySelect
|
||||
value={form.pays_exposition || 'FR'}
|
||||
onChange={code => setForm(f => ({ ...f, pays_exposition: code }))}
|
||||
width={720}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Émetteur</label>
|
||||
<input value={form.emetteur} onChange={e => setForm({ ...form, emetteur: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<textarea rows={2} value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal saisie remboursement (depuis projection) ────────── */}
|
||||
<RembFormModal
|
||||
|
||||
@@ -4,12 +4,12 @@ import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import InvSelect from '../components/InvSelect.jsx';
|
||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import CountrySelect from '../components/CountrySelect.jsx';
|
||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||
import InvChart from '../components/InvChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
|
||||
@@ -241,38 +241,6 @@ const empty = {
|
||||
categories_inv_ids: [], secteurs_inv_ids: [],
|
||||
};
|
||||
|
||||
function addMonthsFE(isoDate, n) {
|
||||
if (!isoDate || !n) return '';
|
||||
const [y, m, d] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1 + n, d));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */
|
||||
function lastDayOfMonthFE(isoDate) {
|
||||
if (!isoDate) return isoDate;
|
||||
const [y, m] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute n mois et positionne sur le dernier jour du mois cible.
|
||||
* Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév).
|
||||
*/
|
||||
function addMonthsFinDeMoisFE(isoDate, n) {
|
||||
if (!isoDate || (n === undefined || n === null)) return '';
|
||||
const [y, m] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */
|
||||
function dayOfMonth(isoDate) {
|
||||
if (!isoDate || isoDate.length < 10) return 0;
|
||||
return parseInt(isoDate.slice(8, 10), 10);
|
||||
}
|
||||
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
export default function Investissements() {
|
||||
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||||
@@ -700,6 +668,33 @@ export default function Investissements() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
|
||||
* le détenteur par défaut, et préremplit catégories/secteurs depuis la plateforme. */
|
||||
const handlePlateformeChange = (platId) => {
|
||||
const platObj = plats.find(p => String(p.id) === String(platId));
|
||||
const platMethode = platObj?.methode_remboursement;
|
||||
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
||||
if (platId) {
|
||||
Promise.all([
|
||||
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
|
||||
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
|
||||
]).then(([platCats, platSects]) => {
|
||||
setForm(f => ({ ...f,
|
||||
plateforme_id: platId,
|
||||
investisseur_id: investisseurForPlat(platId),
|
||||
methode_remboursement: newMethode, compte_id: '',
|
||||
categories_inv_ids: platCats.map(c => c.id),
|
||||
secteurs_inv_ids: platSects.map(s => s.id),
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryAdded = (cat) => setCategoriesInv(prev => [...prev, cat].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||
const handleSectorAdded = (sect) => setSecteursInv(prev => [...prev, sect].sort((a, b) => a.nom.localeCompare(b.nom)));
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); };
|
||||
|
||||
const submit = async (e) => {
|
||||
@@ -1282,347 +1277,26 @@ export default function Investissements() {
|
||||
)}
|
||||
|
||||
{/* ── Modal création / édition ── */}
|
||||
<Modal
|
||||
<InvestissementFormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
form={form}
|
||||
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
|
||||
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
|
||||
err={err}
|
||||
plats={plats}
|
||||
multiDetenteur={multiDetenteur}
|
||||
investisseurs={investisseurs}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
onPlateformeChange={handlePlateformeChange}
|
||||
categoriesInv={categoriesInv}
|
||||
secteursInv={secteursInv}
|
||||
onCategoryAdded={handleCategoryAdded}
|
||||
onSectorAdded={handleSectorAdded}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={close}>Annuler</button>
|
||||
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
const platObj = plats.find(p => String(p.id) === String(platId));
|
||||
const platMethode = platObj?.methode_remboursement;
|
||||
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
|
||||
// Pré-remplir catégories/secteurs depuis la plateforme
|
||||
if (platId) {
|
||||
Promise.all([
|
||||
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
|
||||
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
|
||||
]).then(([platCats, platSects]) => {
|
||||
setForm(f => ({ ...f,
|
||||
plateforme_id: platId,
|
||||
investisseur_id: investisseurForPlat(platId),
|
||||
methode_remboursement: newMethode, compte_id: '',
|
||||
categories_inv_ids: platCats.map(c => c.id),
|
||||
secteurs_inv_ids: platSects.map(s => s.id),
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
|
||||
}
|
||||
}}>
|
||||
<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>
|
||||
{(() => {
|
||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
||||
if (!platMethode) return null;
|
||||
if (platMethode === 'choix_investisseur') return (
|
||||
<div>
|
||||
<label>Méthode de remboursement</label>
|
||||
<select value={form.methode_remboursement} onChange={e => {
|
||||
const methode = e.target.value;
|
||||
const defaultCompte = methode === 'compte_courant'
|
||||
? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0])
|
||||
: null;
|
||||
setForm({ ...form, methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') });
|
||||
}}>
|
||||
<option value="">— Non renseignée —</option>
|
||||
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
||||
<option value="compte_courant">Compte courant de l'investisseur</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
// Méthode fixée par la plateforme — lecture seule
|
||||
return (
|
||||
<div>
|
||||
<label>Méthode de remboursement</label>
|
||||
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
|
||||
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{form.methode_remboursement === 'compte_courant' && (
|
||||
<div>
|
||||
<label>Compte de réception *</label>
|
||||
{comptesInvestisseur.length > 0 ? (
|
||||
<select
|
||||
required
|
||||
value={form.compte_id}
|
||||
onChange={e => {
|
||||
const c = comptesInvestisseur.find(c => String(c.id) === e.target.value);
|
||||
setForm({ ...form, compte_id: e.target.value, nom_compte_courant: c?.nom || '' });
|
||||
}}
|
||||
>
|
||||
<option value="">— Choisir un compte —</option>
|
||||
{comptesInvestisseur.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
|
||||
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Détenteur auto-déduit de la plateforme — champ masqué */}
|
||||
<input type="hidden" value={form.investisseur_id} />
|
||||
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Nom du projet *</label>
|
||||
<input required value={form.nom_projet} onChange={e => setForm({ ...form, nom_projet: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Montant investi (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.montant_investi}
|
||||
onChange={e => setForm({ ...form, montant_investi: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Taux annuel (%) *</label>
|
||||
<input type="number" step="0.01" required value={form.taux_interet}
|
||||
onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Durée (mois) *</label>
|
||||
<input type="number" min="0" required value={form.duree_mois} onChange={e => {
|
||||
const duree = e.target.value;
|
||||
const next = { ...form, duree_mois: duree };
|
||||
if (duree) {
|
||||
if (form.type_remb === 'differe' && form.date_souscription) {
|
||||
const tempDpe = addMonthsFE(form.date_souscription, 1);
|
||||
const cible = addMonthsFE(tempDpe, Number(duree) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
} else if (form.date_premiere_echeance) {
|
||||
next.date_cible = addMonthsFE(form.date_premiere_echeance, Number(duree) - 1);
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Type de prêt</label>
|
||||
<select value={form.type_remb} onChange={e => {
|
||||
const t = e.target.value;
|
||||
const next = {
|
||||
...form, type_remb: t,
|
||||
freq_interets: t === 'differe' ? 'in_fine' : (form.freq_interets === 'in_fine' ? 'mensuel' : form.freq_interets),
|
||||
};
|
||||
if (t === 'differe') {
|
||||
// Recalculer la date d'échéance depuis souscription+durée (prioritaire sur l'ancienne valeur)
|
||||
if (form.date_souscription && form.duree_mois) {
|
||||
const dpe = addMonthsFE(form.date_souscription, 1);
|
||||
const cible = addMonthsFE(dpe, Number(form.duree_mois) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
} else if (form.date_premiere_echeance) {
|
||||
next.date_cible = form.date_premiere_echeance;
|
||||
}
|
||||
} else {
|
||||
if (form.date_souscription) {
|
||||
next.date_premiere_echeance = addMonthsFE(form.date_souscription, 1);
|
||||
if (next.date_premiere_echeance && form.duree_mois) {
|
||||
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(form.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
}}>
|
||||
<option value="in_fine">Prêt in fine</option>
|
||||
<option value="amortissable">Prêt amortissable</option>
|
||||
<option value="differe">Prêt différé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fréquence intérêts</label>
|
||||
<select
|
||||
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
|
||||
disabled={form.type_remb === 'differe'}
|
||||
onChange={e => setForm({ ...form, freq_interets: e.target.value })}
|
||||
>
|
||||
{form.type_remb === 'differe' ? (
|
||||
<option value="in_fine">In fine (unique)</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="mensuel">Mensuel</option>
|
||||
<option value="trimestriel">Trimestriel</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date souscription *</label>
|
||||
<input type="date" required value={form.date_souscription} onChange={e => {
|
||||
const souscription = e.target.value;
|
||||
setForm(prev => {
|
||||
const next = { ...prev, date_souscription: souscription };
|
||||
if (!souscription || souscription.length !== 10) return next;
|
||||
if (prev.type_remb === 'differe') {
|
||||
if (prev.duree_mois) {
|
||||
const tempDpe = addMonthsFE(souscription, 1);
|
||||
const cible = addMonthsFE(tempDpe, Number(prev.duree_mois) - 1);
|
||||
next.date_premiere_echeance = cible;
|
||||
next.date_cible = cible;
|
||||
}
|
||||
} else {
|
||||
next.date_premiere_echeance = addMonthsFE(souscription, 1);
|
||||
if (next.date_premiere_echeance && prev.duree_mois) {
|
||||
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(prev.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (souscription && souscription.length === 10) {
|
||||
lastValidSouscriptionRef.current = souscription;
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label>{form.type_remb === 'differe' ? "Date d'échéance (versement unique) *" : 'Date 1ère échéance *'}</label>
|
||||
<input type="date" required value={form.date_premiere_echeance}
|
||||
onChange={e => {
|
||||
const dpe = e.target.value;
|
||||
const next = { ...form, date_premiere_echeance: dpe };
|
||||
// Si le jour passe à ≤ 27 on désactive fin-de-mois
|
||||
if (dayOfMonth(dpe) <= 27) next.echeance_fin_de_mois = false;
|
||||
if (form.type_remb === 'differe') {
|
||||
next.date_cible = dpe;
|
||||
} else if (dpe && form.duree_mois) {
|
||||
next.date_cible = next.echeance_fin_de_mois
|
||||
? addMonthsFinDeMoisFE(dpe, Number(form.duree_mois) - 1)
|
||||
: addMonthsFE(dpe, Number(form.duree_mois) - 1);
|
||||
}
|
||||
setForm(next);
|
||||
}}
|
||||
/>
|
||||
{/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */}
|
||||
{(form.type_remb === 'in_fine' || form.type_remb === 'differe') &&
|
||||
dayOfMonth(form.date_premiere_echeance) > 27 && (
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
marginTop: 6, cursor: 'pointer', userSelect: 'none',
|
||||
fontSize: 'var(--fs-sm)', color: 'var(--text)',
|
||||
width: 'fit-content',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', flexShrink: 0 }}
|
||||
checked={form.echeance_fin_de_mois}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
setForm(prev => {
|
||||
const next = { ...prev, echeance_fin_de_mois: checked };
|
||||
if (checked && prev.date_premiere_echeance) {
|
||||
const newDpe = lastDayOfMonthFE(prev.date_premiere_echeance);
|
||||
next.date_premiere_echeance = newDpe;
|
||||
if (prev.type_remb === 'differe') {
|
||||
next.date_cible = newDpe;
|
||||
} else if (newDpe && prev.duree_mois) {
|
||||
next.date_cible = addMonthsFinDeMoisFE(newDpe, Number(prev.duree_mois) - 1);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Dernier jour du mois
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' — calculée' : ''}</label>
|
||||
<input type="date" value={form.date_cible}
|
||||
readOnly={form.type_remb === 'differe'}
|
||||
onChange={e => setForm({ ...form, date_cible: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Pays d'exposition</label>
|
||||
<CountrySelect
|
||||
value={form.pays_exposition || 'FR'}
|
||||
onChange={code => setForm(f => ({ ...f, pays_exposition: code }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Émetteur</label>
|
||||
<input value={form.emetteur}
|
||||
onChange={e => setForm({ ...form, emetteur: e.target.value })}
|
||||
placeholder="Nom de l'emprunteur"
|
||||
/>
|
||||
</div>
|
||||
{editingId && (
|
||||
<div>
|
||||
<label>Statut</label>
|
||||
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
|
||||
<option value="en_cours">En cours</option>
|
||||
<option value="rembourse">Remboursé</option>
|
||||
<option value="en_retard">En retard</option>
|
||||
<option value="procedure">Procédure</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Référence</label>
|
||||
<input value={form.reference}
|
||||
onChange={e => setForm({ ...form, reference: e.target.value })}
|
||||
placeholder="N° de dossier, code projet…"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Notes</label>
|
||||
<textarea rows={3} value={form.notes}
|
||||
onChange={e => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Informations complémentaires…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Catégories d'investissement */}
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<label>Catégories d'investissement</label>
|
||||
<InvSelect
|
||||
items={categoriesInv}
|
||||
selected={form.categories_inv_ids || []}
|
||||
onChange={ids => setForm(f => ({ ...f, categories_inv_ids: ids }))}
|
||||
addApiPath="/categories-inv"
|
||||
onItemAdded={cat => setCategoriesInv(prev => [...prev, cat].sort((a,b) => a.nom.localeCompare(b.nom)))}
|
||||
emptyLabel="Aucune catégorie d'investissement"
|
||||
addLabel="Ajouter une catégorie d'investissement"
|
||||
inputPlaceholder="Nom de la catégorie…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secteurs d'investissement */}
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<label>Secteurs d'investissement</label>
|
||||
<InvSelect
|
||||
items={secteursInv}
|
||||
selected={form.secteurs_inv_ids || []}
|
||||
onChange={ids => setForm(f => ({ ...f, secteurs_inv_ids: ids }))}
|
||||
addApiPath="/secteurs-inv"
|
||||
onItemAdded={sect => setSecteursInv(prev => [...prev, sect].sort((a,b) => a.nom.localeCompare(b.nom)))}
|
||||
emptyLabel="Aucun secteur d'investissement"
|
||||
addLabel="Ajouter un secteur d'investissement"
|
||||
inputPlaceholder="Nom du secteur…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{openMenu && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Helpers de calcul de dates pour les formulaires investissement
|
||||
* (échéances, durée, fin de mois). Utilisés par InvestissementFormModal
|
||||
* ainsi que par les pages Investissements / InvestissementDetail (ex. modale
|
||||
* de révision des conditions).
|
||||
*
|
||||
* Extrait de code auparavant dupliqué à l'identique dans Investissements.jsx
|
||||
* et InvestissementDetail.jsx.
|
||||
*/
|
||||
|
||||
/** Ajoute n mois (calendaires, JS Date) à une date ISO. */
|
||||
export function addMonthsFE(isoDate, n) {
|
||||
if (!isoDate || !n) return '';
|
||||
const [y, m, d] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1 + n, d));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */
|
||||
export function lastDayOfMonthFE(isoDate) {
|
||||
if (!isoDate) return isoDate;
|
||||
const [y, m] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute n mois et positionne sur le dernier jour du mois cible.
|
||||
* Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév).
|
||||
*/
|
||||
export function addMonthsFinDeMoisFE(isoDate, n) {
|
||||
if (!isoDate || (n === undefined || n === null)) return '';
|
||||
const [y, m] = isoDate.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */
|
||||
export function dayOfMonth(isoDate) {
|
||||
if (!isoDate || isoDate.length < 10) return 0;
|
||||
return parseInt(isoDate.slice(8, 10), 10);
|
||||
}
|
||||
Generated
+15
-15
@@ -230,9 +230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "1.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
|
||||
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -724,9 +724,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
|
||||
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -861,9 +861,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.30",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz",
|
||||
"integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==",
|
||||
"version": "4.13.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
|
||||
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -924,9 +924,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -1556,9 +1556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
Reference in new issue
Block a user