Compare commits

..
3 Commits
Author SHA1 Message Date
ocroguennec c7bbe5dee6 Amélioration de la modale Investiisement 2026-08-29 15:14:02 +02:00
ocroguennec 44b8e5eda0 Update de securité 2026-08-29 13:56:28 +02:00
ocroguennec 7519b7507f Nouveau KPI 2026-08-29 12:42:31 +02:00
13 changed files with 1045 additions and 1099 deletions

No files matched your search

+4
View File
@@ -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
+25 -123
View File
@@ -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"
},
+1 -1
View File
@@ -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": {
+3
View File
@@ -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';
+3
View File
@@ -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';
+135 -222
View File
@@ -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"
},
+1 -1
View File
@@ -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>
);
}
+217 -2
View File
@@ -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>
+69 -364
View File
@@ -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
+45 -371
View File
@@ -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 && (
<>
+42
View File
@@ -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);
}
+15 -15
View File
@@ -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"