diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 6fe09e2..6025f37 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1960,6 +1960,10 @@ console.log('[DB] Migrations 2FA OK'); db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8"); console.log('[DB] Colonne smtp_config.min_password_length ajoutée'); } + if (!cols.includes('mcp_url')) { + db.exec("ALTER TABLE smtp_config ADD COLUMN mcp_url TEXT NOT NULL DEFAULT ''"); + console.log('[DB] Colonne smtp_config.mcp_url ajoutée'); + } // ── Migration : email sur investisseurs ─────────────────────────────── const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name); diff --git a/backend/src/routes/general.js b/backend/src/routes/general.js index 9db8a9e..72fc362 100644 --- a/backend/src/routes/general.js +++ b/backend/src/routes/general.js @@ -29,10 +29,11 @@ function ensureRow() { router.get('/', (_req, res, next) => { try { ensureRow(); - const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); + const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); res.json({ appName: row.app_name || 'Crowdlending Tracker', appUrl: row.app_url || '', + mcpUrl: row.mcp_url || '', allowRegistration: row.allow_registration !== 0, minPasswordLength: row.min_password_length || 8, }); @@ -42,6 +43,7 @@ router.get('/', (_req, res, next) => { const PatchSchema = z.object({ appName: z.string().min(1).max(100).optional(), appUrl: z.string().max(500).optional(), + mcpUrl: z.string().max(500).optional(), allowRegistration: z.boolean().optional(), minPasswordLength: z.number().int().min(6).max(64).optional(), }); @@ -50,18 +52,20 @@ router.patch('/', (req, res, next) => { try { ensureRow(); const body = PatchSchema.parse(req.body); - const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); + const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); db.prepare(` UPDATE smtp_config SET app_name = ?, app_url = ?, + mcp_url = ?, allow_registration = ?, min_password_length = ? WHERE id = 1 `).run( body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'), body.appUrl !== undefined ? body.appUrl : (row.app_url || ''), + body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''), body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0), body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8), ); diff --git a/backend/src/server.js b/backend/src/server.js index f0cc2d8..d58fdc9 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -94,15 +94,16 @@ app.get('/api/app-info', (_, res) => { try { const cfg = getSmtpConfig(); const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); - const row = db.prepare('SELECT allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); + const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get(); res.json({ appName: cfg.appName || 'Crowdlending Tracker', iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, allowRegistration: row ? row.allow_registration !== 0 : true, minPasswordLength: row ? (row.min_password_length || 8) : 8, + mcpUrl: row ? (row.mcp_url || '') : '', }); } catch { - res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8 }); + res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' }); } }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e94648..22817ec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "crowdlending-frontend", "version": "0.1.0", "dependencies": { + "html2pdf.js": "^0.14.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", @@ -252,6 +253,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1159,6 +1169,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1189,6 +1219,15 @@ "node": ">=0.8" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", @@ -1257,6 +1296,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -1286,6 +1345,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "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", @@ -1298,6 +1369,15 @@ "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", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1316,6 +1396,15 @@ } } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "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", @@ -1372,6 +1461,23 @@ "node": ">=6" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/frac": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", @@ -1406,6 +1512,36 @@ "node": ">=6.9.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2pdf.js": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz", + "integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0", + "jspdf": "^4.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1438,6 +1574,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1493,6 +1646,29 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1529,6 +1705,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1596,6 +1782,23 @@ "react-dom": ">=16.8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -1682,6 +1885,35 @@ "node": ">=0.8" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1713,6 +1945,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5948b72..3296813 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "html2pdf.js": "^0.14.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", diff --git a/frontend/public/powerquery/README.md b/frontend/public/powerquery/README.md new file mode 100644 index 0000000..33fd9de --- /dev/null +++ b/frontend/public/powerquery/README.md @@ -0,0 +1,112 @@ +# Power Query — exemples de connexion à l'API v1 + +Exemples de requêtes [Power Query](https://learn.microsoft.com/fr-fr/power-query/) (Excel) +pour interroger le portefeuille crowdlending depuis un classeur Excel, en s'appuyant sur +la même API v1 (lecture seule) que le [serveur MCP](../mcp-server/README.md). + +Aucune écriture : ces requêtes ne font que lire des données, comme le serveur MCP. + +## Prérequis + +- Une clé API générée dans l'app : **Mon compte → Clés API → Nouvelle clé** +- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre instance en + production, ex. `https://crowdlending.croguennec.net`) +- Excel avec Power Query (Excel 365 / 2016+ sous Windows ou Mac — Power Query est intégré + nativement, rien à installer) + +## Option 1 — Classeur prêt à l'emploi (`crowdlending-powerquery-exemple.xlsb`) + +Le plus rapide : ouvrez `crowdlending-powerquery-exemple.xlsb` directement dans Excel. + +> **Pourquoi un `.xlsb` et pas un `.xlsx` ?** Le format binaire d'Excel (`.xlsb`) est celui +> à partir duquel ce classeur a pu être construit et vérifié de façon fiable en dehors +> d'Excel. Il s'ouvre et se comporte exactement comme un `.xlsx` — Power Query, tableaux, +> actualisation, tout fonctionne à l'identique. Si vous préférez un `.xlsx`, ouvrez le +> fichier puis **Fichier → Enregistrer sous** et changez le format ; les requêtes suivent. + +Le classeur contient : + +| Requête | Rôle | +|---|---| +| `ApiBaseUrl` | URL de l'API à interroger (à modifier) | +| `ApiKey` | Votre clé API (à modifier — voir ci-dessous) | +| `fnApiGet` | Fonction utilitaire partagée (appel HTTP + parsing JSON), utilisée par toutes les requêtes de données | +| `Investisseur` | Profil investisseur (ou liste des membres si clé « Famille et entreprises ») | +| `Dashboard` | KPIs du portefeuille (capital investi, capital en risque, intérêts, cash), au format « Indicateur / Valeur » | +| `Investissements` | Liste des investissements | +| `Remboursements` | Historique des remboursements | +| `DepotsRetraits` | Historique des mouvements de cash | +| `fnDetailInvestissement` | Fonction avancée : détail + remboursements d'un investissement par id | +| `Test` | Table d'instructions (« À lire avant de commencer ») — pas une donnée métier | + +Étapes : + +1. Ouvrez le classeur. La feuille affiche par défaut un exemple mis en cache (pas encore + vos données) — c'est normal, Excel n'a pas encore appelé l'API. +2. **Données → Requêtes et connexions**. Repérez `ApiKey` dans le volet à droite. +3. Clic droit sur `ApiKey` → **Modifier**. Dans l'éditeur, remplacez + `"clk_live_VOTRE_CLE_API"` par votre vraie clé (entre guillemets), puis + **Fermer et charger**. +4. Vérifiez `ApiBaseUrl` de la même façon : `http://localhost:4000/api/v1` en local, ou + `https://crowdlending.croguennec.net/api/v1` en production (adaptez à votre domaine). +5. **Données → Actualiser tout** (ou Ctrl+Alt+F5). La table « Test » se met à jour avec les + instructions, signe que la connexion fonctionne. +6. Pour chaque requête de données qui vous intéresse (`Investissements`, `Remboursements`…) : + clic droit dans le volet **Requêtes et connexions** → **Charger dans…** → choisissez + Tableau (ou Tableau croisé dynamique) et la feuille de destination. + +## Option 2 — Coller les requêtes manuellement (`queries/*.pq`) + +Utile si vous préférez tout construire vous-même dans un classeur existant, ou si une +requête du classeur ne s'affiche pas correctement chez vous. + +Pour chaque fichier, dans l'ordre ci-dessous : **Données → Obtenir des données → À partir +d'autres sources → Requête vide**, renommez la requête (volet de droite, ou après double-clic +sur son nom) avec **exactement** le nom du fichier (sans `.pq`), puis **Accueil → Éditeur +avancé**, effacez le contenu par défaut, collez le contenu du fichier, **Terminé**. + +Ordre à respecter (chaque requête réutilise les précédentes par leur nom) : + +1. `ApiBaseUrl.pq` — modifiez l'URL avant de coller si besoin +2. `ApiKey.pq` — remplacez `VOTRE_CLE_API` par votre clé avant de coller +3. `fnApiGet.pq` +4. `Investisseur.pq`, `Dashboard.pq`, `Investissements.pq`, `Remboursements.pq`, + `DepotsRetraits.pq` — dans l'ordre que vous voulez +5. `fnDetailInvestissement.pq` (optionnel, usage avancé — voir plus bas) + +Une fois `fnApiGet` créée, **Fermer et charger** chaque requête de données individuellement +(clic droit → Charger dans…) pour l'ajouter comme tableau. + +## Filtres + +Les requêtes `Dashboard`, `Investissements` et `Remboursements` acceptent des filtres côté +API (année, statut, période). Par défaut elles ramènent tout (`null`). Pour filtrer, +ouvrez la requête dans l'éditeur avancé et remplacez `null` par le paramètre indiqué en +commentaire en tête du fichier `.pq` correspondant, par exemple : + +``` +Source = fnApiGet("/investissements", [statut = "rembourse"]) +``` + +Statuts possibles : `en_cours`, `rembourse`, `en_retard`, `procedure`, `cloture`. + +## Usage avancé — détail d'un investissement par ligne + +`fnDetailInvestissement` permet de ramener, pour chaque ligne de la table `Investissements`, +le détail complet (dont les remboursements) sans requête séparée : sur la table +`Investissements`, **Ajout de colonne → Colonne personnalisée**, formule +`= fnDetailInvestissement([id])`. Attention : ceci déclenche un appel API par ligne — à +réserver à un nombre raisonnable d'investissements (quelques dizaines). + +## Dépannage + +- **`Impossible de se connecter au service distant`** — le backend n'est pas démarré, ou + `ApiBaseUrl` est incorrecte (vérifiez le port et le suffixe `/api/v1`). +- **`Erreur API (401)` / `Clé API invalide ou révoquée`** — régénérez une clé dans + Mon compte → Clés API et remplacez la valeur de `ApiKey`. +- **Une requête du classeur n'apparaît pas dans le volet, ou affiche une erreur au premier + chargement** — repartez de l'Option 2 pour cette requête précise : créez une requête + vide portant son nom et collez le contenu du `.pq` correspondant. +- **Avertissement de sécurité / niveau de confidentialité au premier chargement** — normal + pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou + « Public » selon votre contexte, ce n'est pas spécifique à ce classeur. diff --git a/frontend/public/powerquery/apibaseurl-editeur-avance.png b/frontend/public/powerquery/apibaseurl-editeur-avance.png new file mode 100644 index 0000000..8337273 Binary files /dev/null and b/frontend/public/powerquery/apibaseurl-editeur-avance.png differ diff --git a/frontend/public/powerquery/apikey-editeur-avance.png b/frontend/public/powerquery/apikey-editeur-avance.png new file mode 100644 index 0000000..2108915 Binary files /dev/null and b/frontend/public/powerquery/apikey-editeur-avance.png differ diff --git a/frontend/public/powerquery/crowdlending-powerquery-exemple.xlsb b/frontend/public/powerquery/crowdlending-powerquery-exemple.xlsb new file mode 100644 index 0000000..f178fce Binary files /dev/null and b/frontend/public/powerquery/crowdlending-powerquery-exemple.xlsb differ diff --git a/frontend/public/powerquery/fnapiget-editeur-avance.png b/frontend/public/powerquery/fnapiget-editeur-avance.png new file mode 100644 index 0000000..0273956 Binary files /dev/null and b/frontend/public/powerquery/fnapiget-editeur-avance.png differ diff --git a/frontend/public/powerquery/investissements-editeur-avance.png b/frontend/public/powerquery/investissements-editeur-avance.png new file mode 100644 index 0000000..6c13478 Binary files /dev/null and b/frontend/public/powerquery/investissements-editeur-avance.png differ diff --git a/frontend/public/powerquery/queries/ApiBaseUrl.pq b/frontend/public/powerquery/queries/ApiBaseUrl.pq new file mode 100644 index 0000000..217d265 --- /dev/null +++ b/frontend/public/powerquery/queries/ApiBaseUrl.pq @@ -0,0 +1,3 @@ +// URL de base de l'API v1. En local : http://localhost:4000/api/v1 +// En production : https://votre-domaine/api/v1 +"http://localhost:4000/api/v1" diff --git a/frontend/public/powerquery/queries/ApiKey.pq b/frontend/public/powerquery/queries/ApiKey.pq new file mode 100644 index 0000000..9547d41 --- /dev/null +++ b/frontend/public/powerquery/queries/ApiKey.pq @@ -0,0 +1,2 @@ +// Clé API générée dans l'app : Mon compte > Clés API > Nouvelle clé +"clk_live_VOTRE_CLE_API" diff --git a/frontend/public/powerquery/queries/Dashboard.pq b/frontend/public/powerquery/queries/Dashboard.pq new file mode 100644 index 0000000..72a05bf --- /dev/null +++ b/frontend/public/powerquery/queries/Dashboard.pq @@ -0,0 +1,14 @@ +let + // Pour filtrer sur une année, remplacez null par [annee = "2026"] + Source = fnApiGet("/dashboard", null), + VersLignes = (enregistrement as record, nomSection as text) => + Table.AddColumn(Record.ToTable(enregistrement), "Section", each nomSection), + Combine = Table.Combine({ + VersLignes(Source[investissements], "Investissements"), + VersLignes(Source[interets], "Interets"), + VersLignes(Source[cash], "Cash") + }), + Reordonne = Table.ReorderColumns(Combine, {"Section", "Name", "Value"}), + Resultat = Table.RenameColumns(Reordonne, {{"Name", "Indicateur"}, {"Value", "Valeur"}}) +in + Resultat diff --git a/frontend/public/powerquery/queries/DepotsRetraits.pq b/frontend/public/powerquery/queries/DepotsRetraits.pq new file mode 100644 index 0000000..91448d3 --- /dev/null +++ b/frontend/public/powerquery/queries/DepotsRetraits.pq @@ -0,0 +1,5 @@ +let + Source = fnApiGet("/depots-retraits", null), + Resultat = Table.FromRecords(Source) +in + Resultat diff --git a/frontend/public/powerquery/queries/Investissements.pq b/frontend/public/powerquery/queries/Investissements.pq new file mode 100644 index 0000000..85303be --- /dev/null +++ b/frontend/public/powerquery/queries/Investissements.pq @@ -0,0 +1,7 @@ +let + // Pour filtrer par statut, remplacez null par [statut = "rembourse"] + // Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture + Source = fnApiGet("/investissements", null), + Resultat = Table.FromRecords(Source) +in + Resultat diff --git a/frontend/public/powerquery/queries/Investisseur.pq b/frontend/public/powerquery/queries/Investisseur.pq new file mode 100644 index 0000000..939d14f --- /dev/null +++ b/frontend/public/powerquery/queries/Investisseur.pq @@ -0,0 +1,6 @@ +let + Source = fnApiGet("/investisseur", null), + // Clé "Famille et entreprises" -> liste ; clé mono-investisseur -> objet unique + Resultat = if Value.Is(Source, type list) then Table.FromRecords(Source) else Table.FromRecords({Source}) +in + Resultat diff --git a/frontend/public/powerquery/queries/Remboursements.pq b/frontend/public/powerquery/queries/Remboursements.pq new file mode 100644 index 0000000..5026748 --- /dev/null +++ b/frontend/public/powerquery/queries/Remboursements.pq @@ -0,0 +1,6 @@ +let + // Pour filtrer par période, remplacez null par [date_debut = "2026-01-01", date_fin = "2026-12-31"] + Source = fnApiGet("/remboursements", null), + Resultat = Table.FromRecords(Source) +in + Resultat diff --git a/frontend/public/powerquery/queries/fnApiGet.pq b/frontend/public/powerquery/queries/fnApiGet.pq new file mode 100644 index 0000000..221740e --- /dev/null +++ b/frontend/public/powerquery/queries/fnApiGet.pq @@ -0,0 +1,19 @@ +(chemin as text, optional parametres as nullable record) as any => + let + parametresBruts = if parametres = null then [] else parametres, + champsUtiles = List.Select( + Record.FieldNames(parametresBruts), + each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> "" + ), + parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles), + reponse = Web.Contents( + ApiBaseUrl, + [ + RelativePath = chemin, + Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"], + Query = parametresNettoyes + ] + ), + resultat = Json.Document(reponse) + in + resultat diff --git a/frontend/public/powerquery/queries/fnDetailInvestissement.pq b/frontend/public/powerquery/queries/fnDetailInvestissement.pq new file mode 100644 index 0000000..c9b6dc0 --- /dev/null +++ b/frontend/public/powerquery/queries/fnDetailInvestissement.pq @@ -0,0 +1,5 @@ +// Astuce avancée : sur la table Investissements, colonne personnalisée +// "= fnDetailInvestissement([id])" pour ramener le détail + les remboursements +// de chaque prêt (Ajout de colonne > Colonne personnalisée). +(id as number) as record => + fnApiGet("/investissements/" & Text.From(id), null) diff --git a/frontend/src/pages/Aide.jsx b/frontend/src/pages/Aide.jsx index 38920a4..34f9f2c 100644 --- a/frontend/src/pages/Aide.jsx +++ b/frontend/src/pages/Aide.jsx @@ -1,21 +1,107 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { withDevOverrides } from '../utils/devOverrides.js'; + +/** Nom de fichier à partir de la question (slug, sans accents). */ +function slugify(text) { + return text + .toLowerCase() + .normalize('NFD').replace(new RegExp('[\\u0300-\\u036f]', 'g'), '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} /* ── Accordéon FAQ ───────────────────────────────────────────── */ -function FaqItem({ question, children }) { - const [open, setOpen] = useState(false); +/* `openQuestion`/`setOpenQuestion` sont partagés par toutes les FaqItem d'une + même page (état remonté dans le composant parent) : une seule question + ouverte à la fois, ouvrir la suivante referme automatiquement les autres. */ +function FaqItem({ question, children, openQuestion, setOpenQuestion }) { + const open = openQuestion === question; + const toggle = () => setOpenQuestion(open ? null : question); + const [pdfState, setPdfState] = useState('idle'); // idle | generating | error + const contentRef = useRef(null); + + const handleDownloadPdf = async () => { + if (!contentRef.current || pdfState === 'generating') return; + setPdfState('generating'); + // Insère le titre directement dans le bloc réel (contentRef), le temps de + // la capture uniquement, puis le retire — évite de le dupliquer en + // permanence à l'écran tout en réutilisant l'élément réellement rendu + // (un conteneur hors-écran séparé est capturé vide par html2canvas : + // sa zone de rendu ne suit pas un élément poussé loin hors du viewport). + let heading = null; + try { + const { default: html2pdf } = await import('html2pdf.js'); + // Fond capturé = fond réel du bloc (var(--surface)) : reste cohérent + // que le thème actif soit clair ou sombre, plutôt qu'un blanc forcé + // qui casserait le contraste du texte en mode sombre. + const bgColor = getComputedStyle(contentRef.current).backgroundColor || '#ffffff'; + + heading = document.createElement('h3'); + heading.textContent = question; + heading.style.margin = '0 0 12px'; + heading.style.color = 'var(--text)'; + heading.style.fontSize = '1.1rem'; + contentRef.current.insertBefore(heading, contentRef.current.firstChild); + + await html2pdf() + .set({ + margin: 28, + filename: `faq-${slugify(question) || 'crowdlending'}.pdf`, + image: { type: 'jpeg', quality: 0.95 }, + html2canvas: { scale: 2, useCORS: true, backgroundColor: bgColor }, + jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait' }, + // 'avoid-all' : ne coupe jamais un élément (image, bloc de code, + // ligne de tableau…) au milieu — le pousse entièrement sur la page + // suivante à la place. C'est ce qui manquait avec le moteur HTML + // interne de jsPDF (doc.html()), qui tranchait au hasard. + pagebreak: { mode: ['avoid-all', 'css'] }, + }) + .from(contentRef.current) + .toPdf() + .get('pdf') + .then((pdf) => { + // Numérotation en bas à droite, une fois le nombre total de pages connu. + const total = pdf.internal.getNumberOfPages(); + for (let i = 1; i <= total; i++) { + pdf.setPage(i); + pdf.setFontSize(9); + pdf.setTextColor(150); + pdf.text( + `${i} / ${total}`, + pdf.internal.pageSize.getWidth() - 28, + pdf.internal.pageSize.getHeight() - 16, + { align: 'right' } + ); + } + }) + .save(); + setPdfState('idle'); + } catch (e) { + console.error('Échec de la génération du PDF :', e); + setPdfState('error'); + } finally { + if (heading && heading.parentNode) heading.parentNode.removeChild(heading); + } + }; + return (
{open && (
- {children} +
+ {children} +
+ + + {pdfState === 'error' && ( +

+ La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez + « Enregistrer en PDF ». +

+ )}
)}
); } +/* ── Titre de section (regroupement thématique de la FAQ) ──────── */ +function FaqSectionTitle({ children, first }) { + return ( +

+ {children} +

+ ); +} + +/* ── Lien de téléchargement (fichiers statiques dans public/) ──── */ +function DownloadLink({ href, children }) { + return ( + + + + + + + {children} + + ); +} + /* ── Navigation ─────────────────────────────────────────────── */ const NAV = [ { @@ -59,6 +210,17 @@ const NAV = [ export default function Aide() { const { search } = useLocation(); const navigate = useNavigate(); + const [openQuestion, setOpenQuestion] = useState(null); // question ouverte dans la FAQ (une seule à la fois) + const [appInfo, setAppInfo] = useState({}); // { appUrl, mcpUrl } — valeurs brutes de la base, telles quelles + + useEffect(() => { + fetch('/api/app-info').then(r => r.json()).then(setAppInfo).catch(() => {}); + }, []); + + // URL MCP annoncée comme « déjà pré-remplie » : doit refléter ce que + // Mon compte → Serveur MCP affichera réellement, y compris en dev (où + // cette page-là retombe elle-même sur localhost via withDevOverrides). + const mcpConfigUrl = withDevOverrides(appInfo).mcpUrl || 'https://mcp./mcp'; const section = new URLSearchParams(search).get('section') || 'faq'; const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true }); @@ -86,9 +248,11 @@ export default function Aide() { {section === 'faq' && (
-

Questions fréquentes

+

Questions fréquentes

- + Comprendre la plateforme + +

Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme, c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit : @@ -137,7 +301,7 @@ export default function Aide() { permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).

- +

Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement, sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt, @@ -179,41 +343,26 @@ export default function Aide() {

- -

- Le serveur MCP tourne déjà en continu en production (service Docker crowdlending-mcp, - exposé via Traefik) — contrairement au développement local, vous n'avez rien à démarrer ni - à laisser tourner sur votre machine. Il suffit de connecter Claude Desktop à l'URL publique. -

+ API et Serveur MCP pour l'IA -

Prérequis

-
    -
  • Le service crowdlending-mcp du - docker-compose.yml doit être déployé, avec un enregistrement - DNS pour mcp.<votre domaine> pointant vers la même IP que l'app - (certificat TLS automatique via Traefik).
  • -
  • Une clé API dédiée à la production, distincte de celle utilisée en développement - local si vous en avez une — cela permet de révoquer l'une sans affecter l'autre.
  • -
+ +

+ Le serveur MCP tourne déjà en continu (service Docker crowdlending-mcp, + exposé via Traefik) — vous n'avez rien à installer ni à laisser tourner sur votre machine. Il + suffit de connecter Claude Desktop à l'URL publique. +

Étapes

    -
  1. Générez une clé API dédiée : Mon compte → Clés API → Nouvelle clé (par exemple nommée « MCP Prod »).
  2. +
  3. Générez une clé API : Mon compte → Clés API → Nouvelle clé (par exemple nommée « Claude Desktop »).
  4. Allez dans Mon compte → Serveur MCP et renseignez l'URL publique - (https://mcp.<votre domaine>/mcp) ainsi que la clé générée. L'environnement - se détecte automatiquement sur « PROD » dès que l'URL ne contient ni localhost ni - dev — cochez « Forcer manuellement » si votre domaine de test prête à confusion.
  5. + ({mcpConfigUrl}{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.
  6. Copiez la configuration générée dans claude_desktop_config.json (Réglages - → Développeur → Serveurs MCP locaux → Modifier la config), exactement comme en développement — seule l'URL change, - le mécanisme mcp-remote (et le wrapper cmd /c sous - Windows) reste identique.
  7. + → Développeur → Serveurs MCP locaux → Modifier la config) — le mécanisme passe par + mcp-remote (avec le wrapper cmd /c sous + Windows), généré automatiquement pour vous.
  8. Redémarrez complètement Claude Desktop.
-

- Vous pouvez connecter dev et prod simultanément : répétez ces étapes une seconde fois - avec l'URL locale et une clé distincte, les deux entrées de config (crowdlending-dev / - crowdlending-prod) coexistent sans collision. -

Sécurité

    @@ -223,16 +372,16 @@ export default function Aide() {
  • Limite de 60 requêtes/minute par IP (au-delà, erreur 429) et fermeture automatique des sessions inactives depuis plus de 30 minutes — aucune donnée de session n'est conservée entre deux connexions.
  • L'outil crowdlending_fetch_url (lecture d'une page web arbitraire) reste - désactivé en production, même s'il est activé chez vous en développement local.
  • + désactivé sur ce serveur.

- Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même qu'en développement local - (npx qui échoue silencieusement à joindre le registre npm) — voir la + Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même : + npx qui échoue silencieusement à joindre le registre npm — voir la section Dépannage de Mon compte → Serveur MCP.

- +

Une fois connecté, Claude (Desktop ou tout autre client MCP) peut consulter votre portefeuille en langage naturel — il choisit lui-même le bon outil selon votre question. Le serveur est strictement @@ -309,6 +458,214 @@ export default function Aide() {

+ +

+ Comme le serveur MCP, Power Query s'appuie sur l'API v1 (lecture seule) via une clé API. + Power Query est intégré nativement à Excel (365 / 2016 et plus, Windows ou Mac) — rien à + installer. Chaque étape ci-dessous correspond à un écran que vous pouvez capturer pour + illustrer votre propre guide. +

+ +
+ + Télécharger le classeur complet (.xlsb) + +
+

+ Le classeur ci-dessus contient déjà les 7 requêtes ci-dessous, prêtes à charger — il ne + reste qu'à renseigner votre clé (étape 1). Vous pouvez aussi tout reconstruire à la main + avec les fichiers .pq individuels + proposés à chaque étape ci-dessous. +

+ +

Étape 1 — Générer une clé API

+
    +
  1. Mon compte → Clés API → Nouvelle clé.
  2. +
  3. Donnez-lui un nom explicite (ex. « Excel »), validez, puis copiez immédiatement la clé + affichée — elle ne sera plus visible en clair ensuite.
  4. +
+ +

Étape 2 — Créer les deux requêtes de connexion

+

+ Dans Excel : Données → Obtenir des données → À partir d'autres + sources → Requête vide. Une nouvelle requête « Requête1 » apparaît dans l'éditeur Power Query. +

+
    +
  1. Renommez-la ApiBaseUrl (double-clic + sur son nom dans le volet Requêtes), puis Affichage → Éditeur avancé, + effacez le contenu par défaut et collez :
  2. +
+
+{`"http://localhost:4000/api/v1"`}
+              
+ Télécharger ApiBaseUrl.pq + Éditeur avancé Power Query — requête ApiBaseUrl avec l'URL de l'API en production { e.currentTarget.style.display = 'none'; }} + /> +
    +
  1. Cliquez Terminé, puis Accueil → Fermer et charger dans… → Uniquement créer la connexion (pas besoin d'un tableau pour celle-ci).
  2. +
  3. Répétez l'opération pour une seconde requête vide nommée ApiKey :
  4. +
+
+{`"clk_live_VOTRE_CLE_API"`}
+              
+ Télécharger ApiKey.pq + Éditeur avancé Power Query — requête ApiKey avec la clé API renseignée { e.currentTarget.style.display = 'none'; }} + /> +

Remplacez par la clé copiée à l'étape 1, entre guillemets. Même chose : Terminé → Uniquement créer la connexion.

+ +

Étape 3 — La fonction technique fnApiGet

+

+ Une troisième requête vide, nommée fnApiGet, + fait l'appel HTTP et transmet la clé dans l'en-tête X-API-Key — + les requêtes suivantes la réutiliseront par son nom, sans la récrire : +

+
+{`(chemin as text, optional parametres as nullable record) as any =>
+    let
+        parametresBruts = if parametres = null then [] else parametres,
+        champsUtiles = List.Select(
+            Record.FieldNames(parametresBruts),
+            each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
+        ),
+        parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
+        reponse = Web.Contents(
+            ApiBaseUrl,
+            [
+                RelativePath = chemin,
+                Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
+                Query = parametresNettoyes
+            ]
+        ),
+        resultat = Json.Document(reponse)
+    in
+        resultat`}
+              
+ Télécharger fnApiGet.pq + Éditeur avancé Power Query — fonction fnApiGet { e.currentTarget.style.display = 'none'; }} + /> +

Terminé → Fermer et charger dans… → Uniquement créer la connexion (ce n'est pas une donnée à afficher).

+ +

Étape 4 — Première requête de données : Investissements

+

Une nouvelle requête vide, nommée Investissements :

+
+{`let
+    // Pour filtrer par statut, remplacez null par [statut = "rembourse"]
+    // Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
+    Source = fnApiGet("/investissements", null),
+    Resultat = Table.FromRecords(Source)
+in
+    Resultat`}
+              
+ Télécharger Investissements.pq + Éditeur avancé Power Query — requête Investissements avec aperçu des données chargées { e.currentTarget.style.display = 'none'; }} + /> +

+ Cette fois-ci, Terminé → Fermer et charger (le bouton + simple, pas « … ») : la requête s'ajoute comme tableau dans une nouvelle feuille, avec vos + investissements en ligne et en colonnes. +

+ +

Étape 5 — Actualiser

+

+ Données → Actualiser tout (ou Ctrl+Alt+F5) à tout moment + pour recharger les données depuis l'API — utile après un nouvel investissement ou remboursement + saisi dans l'app. +

+ +

Aller plus loin — les autres requêtes

+

Même principe (requête vide → nom exact → éditeur avancé → coller → charger) pour :

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Investisseur + Profil investisseur (ou liste des membres si clé « Famille et entreprises »). + .pq +
+ Dashboard + KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD). + .pq +
+ Remboursements + Historique des remboursements, filtrable par période. + .pq +
+ DépôtsRetraits + Historique des mouvements de cash. + .pq +
+ fnDetailInvestissement + + Fonction avancée : sur la table Investissements, colonne personnalisée{' '} + = fnDetailInvestissement([id]){' '} + pour ramener le détail + les remboursements de chaque prêt. + + .pq +
+

+ Toutes ces requêtes sont déjà incluses dans le classeur téléchargeable en haut de cette fiche — + les fichiers .pq ci-dessus + ne sont utiles que si vous préférez les coller vous-même dans un classeur existant. +

+ +

Dépannage

+
    +
  • « Impossible de se connecter au service distant » — le + backend n'est pas démarré, ou ApiBaseUrl est + incorrecte (vérifiez le port et le suffixe /api/v1).
  • +
  • Erreur 401 / clé invalide — régénérez une clé dans + Mon compte → Clés API et mettez à jour la requête ApiKey.
  • +
  • Avertissement de confidentialité au premier chargement — + normal pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou « Public » + selon votre contexte.
  • +
+

+ Comme le serveur MCP, ces requêtes sont strictement en lecture seule : aucune saisie n'est possible + depuis Excel, toute modification reste à faire dans l'application. +

+
+
)} diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx index f19d205..c1e9f5e 100644 --- a/frontend/src/pages/MonCompte.jsx +++ b/frontend/src/pages/MonCompte.jsx @@ -8,6 +8,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import Modal from '../components/Modal.jsx'; import { api } from '../api.js'; import { memberLabel } from '../utils/format.js'; +import { withDevOverrides } from '../utils/devOverrides.js'; /* ── Icônes nav ─────────────────────────────────────────────── */ function IconUser() { @@ -1169,6 +1170,21 @@ function McpServerSection({ goToApiKeys }) { const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel const [debugFlag, setDebugFlag] = useState(false); // ajoute --debug : génère un fichier mcp-server-.log dédié (voir Dépannage) const [systemCaFlag, setSystemCaFlag] = useState(true); // ajoute NODE_OPTIONS=--use-system-ca : contourne un antivirus/proxy qui intercepte le HTTPS (voir Dépannage) — coché par défaut, ne s'applique qu'en prod (voir envBlock) + const mcpUrlEditedRef = useRef(false); // true dès que l'utilisateur touche le champ — n'écrase plus la valeur saisie + + // Pré-remplit avec l'URL renseignée par l'admin (Administration → Général) + // si elle existe, plutôt que de laisser la seule devinette basée sur + // l'hôte courant (utile notamment quand l'app est servie derrière un nom + // de domaine différent du sous-domaine mcp. par défaut). + useEffect(() => { + fetch('/api/app-info').then(r => r.json()).then(d => { + // En dev local, la base est régulièrement une copie de la prod : la + // valeur stockée pointerait encore vers mcp. tant + // qu'on ne la corrige pas à la main — voir utils/devOverrides.js. + const info = withDevOverrides(d); + if (info.mcpUrl && !mcpUrlEditedRef.current) setMcpUrl(info.mcpUrl); + }).catch(() => {}); + }, []); const detectedLabel = detectLabelFromUrl(mcpUrl); const label = manualLabel ?? detectedLabel; @@ -1234,7 +1250,7 @@ function McpServerSection({ goToApiKeys }) {
- setMcpUrl(e.target.value)} /> + { mcpUrlEditedRef.current = true; setMcpUrl(e.target.value); }} />
diff --git a/frontend/src/pages/admin/GeneralSection.jsx b/frontend/src/pages/admin/GeneralSection.jsx index bda91ec..3c1a93c 100644 --- a/frontend/src/pages/admin/GeneralSection.jsx +++ b/frontend/src/pages/admin/GeneralSection.jsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import { api } from '../../api.js'; +import { withDevOverrides } from '../../utils/devOverrides.js'; function SettingRow({ label, description, children }) { return ( @@ -34,6 +35,7 @@ function SectionHeader({ title, description }) { const DEFAULT = { appName: 'Crowdlending Tracker', appUrl: '', + mcpUrl: '', allowRegistration: true, minPasswordLength: 8, }; @@ -46,12 +48,19 @@ export default function GeneralSection() { useEffect(() => { api.get('/admin/general') - .then(d => setForm({ - appName: d.appName || 'Crowdlending Tracker', - appUrl: d.appUrl || '', - allowRegistration: d.allowRegistration !== false, - minPasswordLength: d.minPasswordLength || 8, - })) + .then(d => { + // En dev local, la base est régulièrement une copie de la prod : + // appUrl/mcpUrl y pointeraient encore vers l'environnement de prod + // tant qu'on ne les corrige pas — voir utils/devOverrides.js. + const dd = withDevOverrides(d); + setForm({ + appName: dd.appName || 'Crowdlending Tracker', + appUrl: dd.appUrl || '', + mcpUrl: dd.mcpUrl || '', + allowRegistration: dd.allowRegistration !== false, + minPasswordLength: dd.minPasswordLength || 8, + }); + }) .catch(() => {}) .finally(() => setLoading(false)); }, []); @@ -64,6 +73,7 @@ export default function GeneralSection() { await api.patch('/admin/general', { appName: form.appName.trim(), appUrl: form.appUrl.trim(), + mcpUrl: form.mcpUrl.trim(), allowRegistration: form.allowRegistration, minPasswordLength: form.minPasswordLength, }); @@ -103,7 +113,7 @@ export default function GeneralSection() { /> - + + + + set('mcpUrl', e.target.value)} + style={{ width: '100%' }} + /> +
{/* Accès */} diff --git a/frontend/src/utils/devOverrides.js b/frontend/src/utils/devOverrides.js new file mode 100644 index 0000000..70a3681 --- /dev/null +++ b/frontend/src/utils/devOverrides.js @@ -0,0 +1,27 @@ +/** + * devOverrides.js — Neutralise les paramètres généraux venant de la base en + * développement local. + * + * Contexte : la base SQLite locale est régulièrement remplacée par une copie + * de la base de production (Admin → Export complet, puis restauration en + * local) — ce qui inclut les paramètres généraux (URL de la plateforme, URL + * du serveur MCP). Une fois rejouée en local, cette copie pointe encore vers + * l'environnement de prod tant qu'elle n'a pas été corrigée à la main. + * + * `import.meta.env.DEV` est une constante figée par Vite AU MOMENT DU BUILD : + * `true` uniquement quand le code tourne via `vite` (npm run dev), toujours + * `false` dans un build de production (`vite build`), quel que soit le + * contenu de la base utilisée à l'exécution. Aucun effet possible en + * production, donc. + */ + +export const DEV_APP_URL = 'http://localhost:5173'; +export const DEV_MCP_URL = 'http://localhost:4100/mcp'; + +/** Retourne `info` (forme de /api/app-info ou /api/admin/general) avec + * `appUrl`/`mcpUrl` forcés aux valeurs locales en développement. Inchangé + * tel quel en production. */ +export function withDevOverrides(info) { + if (!import.meta.env.DEV) return info; + return { ...info, appUrl: DEV_APP_URL, mcpUrl: DEV_MCP_URL }; +}