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 (
+ La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez + « Enregistrer en PDF ». +
+ )}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.
-
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).
+ 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.
+
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.{mcpConfigUrl}{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.
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. mcp-remote (avec le wrapper cmd /c sous
+ Windows), généré automatiquement pour vous.
- 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.
-
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.
- 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. +
+ +
+ 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.
+
+ 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. +
+ApiBaseUrl (double-clic
+ sur son nom dans le volet Requêtes), puis Affichage → Éditeur avancé,
+ effacez le contenu par défaut et collez :
+{`"http://localhost:4000/api/v1"`}
+
+
{ e.currentTarget.style.display = 'none'; }}
+ />
+ ApiKey :
+{`"clk_live_VOTRE_CLE_API"`}
+
+
{ 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.
+ +
+ 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`}
+
+
{ e.currentTarget.style.display = 'none'; }}
+ />
+ Terminé → Fermer et charger dans… → Uniquement créer la connexion (ce n'est pas une donnée à afficher).
+ +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`}
+
+
{ 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. +
+ ++ 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. +
+ +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 »). | +
+ |
+
+ Dashboard
+ |
+ KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD). | +
+ |
+
+ Remboursements
+ |
+ Historique des remboursements, filtrable par période. | +
+ |
+
+ DépôtsRetraits
+ |
+ Historique des mouvements de cash. | +
+ |
+
+ fnDetailInvestissement
+ |
+
+ Fonction avancée : sur la table Investissements, colonne personnalisée{' '}
+ = fnDetailInvestissement([id]){' '}
+ pour ramener le détail + les remboursements de chaque prêt.
+ |
+
+ |
+
+ 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.
+
ApiBaseUrl est
+ incorrecte (vérifiez le port et le suffixe /api/v1).ApiKey.+ 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. +
+