Améliorations diverses

This commit is contained in:
2026-07-20 22:12:49 +02:00
parent 441da1975a
commit 41c464e6a3
24 changed files with 907 additions and 55 deletions
+241
View File
@@ -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",
+1
View File
@@ -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",
+112
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -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"
@@ -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"
@@ -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
@@ -0,0 +1,5 @@
let
Source = fnApiGet("/depots-retraits", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
+400 -43
View File
@@ -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 (
<div style={{
borderBottom: '1px solid var(--border)',
padding: '0',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 10,
boxShadow: 'var(--shadow)',
padding: '0 20px',
marginBottom: 12,
}}>
<button
onClick={() => setOpen(o => !o)}
onClick={toggle}
style={{
width: '100%', textAlign: 'left', background: 'none', border: 'none',
padding: '14px 0', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 12,
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
color: 'var(--text)', fontSize: '1.05rem', fontWeight: 600,
}}
>
<span>{question}</span>
@@ -29,16 +115,81 @@ function FaqItem({ question, children }) {
</button>
{open && (
<div style={{
paddingBottom: 16, color: 'var(--text-muted)',
paddingBottom: 20, color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
}}>
{children}
<div ref={contentRef} style={{ background: 'var(--surface)' }}>
{children}
</div>
<button
onClick={handleDownloadPdf}
disabled={pdfState === 'generating'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16,
padding: '7px 14px', border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--surface-2)', color: 'var(--text)',
fontSize: 'var(--fs-sm)', fontWeight: 500,
cursor: pdfState === 'generating' ? 'default' : 'pointer',
opacity: pdfState === 'generating' ? 0.6 : 1,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{pdfState === 'generating' ? 'Génération du PDF…' : 'Télécharger cette FAQ en PDF'}
</button>
{pdfState === 'error' && (
<p style={{ color: 'var(--danger)', fontSize: 'var(--fs-sm)', margin: '8px 0 0' }}>
La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez
« Enregistrer en PDF ».
</p>
)}
</div>
)}
</div>
);
}
/* ── Titre de section (regroupement thématique de la FAQ) ──────── */
function FaqSectionTitle({ children, first }) {
return (
<h3 style={{
margin: first ? '0 0 12px' : '32px 0 12px',
fontSize: '0.8rem', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.06em', color: 'var(--text-muted)',
}}>
{children}
</h3>
);
}
/* ── Lien de téléchargement (fichiers statiques dans public/) ──── */
function DownloadLink({ href, children }) {
return (
<a
href={href}
download
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
color: 'var(--primary)', fontSize: 'var(--fs-sm)', fontWeight: 500,
textDecoration: 'none', margin: '0 0 12px',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{children}
</a>
);
}
/* ── 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.<votre domaine>/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' && (
<div>
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
<h2 style={{ marginTop: 0, marginBottom: 24, fontSize: '1.6rem', fontWeight: 700 }}>Questions fréquentes</h2>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
<FaqSectionTitle first>Comprendre la plateforme</FaqSectionTitle>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
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é).</p>
</FaqItem>
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
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() {
</p>
</FaqItem>
<FaqItem question="Comment configurer le serveur MCP en production (sans passer par le développement local) ?">
<p style={{ marginTop: 0 }}>
Le serveur MCP tourne déjà en continu en production (service Docker <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code>,
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.
</p>
<FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Prérequis</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Le service <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code> du
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> docker-compose.yml</code> doit être déployé, avec un enregistrement
DNS pour <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>mcp.&lt;votre domaine&gt;</code> pointant vers la même IP que l'app
(certificat TLS automatique via Traefik).</li>
<li>Une clé API <strong style={{ color: 'var(--text)' }}>dédiée à la production</strong>, 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.</li>
</ul>
<FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le serveur MCP tourne déjà en continu (service Docker <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code>,
exposé via Traefik) — vous n'avez rien à installer ni à laisser tourner sur votre machine. Il
suffit de connecter Claude Desktop à l'URL publique.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étapes</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Générez une clé API dédiée : <strong style={{ color: 'var(--text)' }}>Mon compte → Clés API → Nouvelle clé</strong> (par exemple nommée « MCP Prod »).</li>
<li>Générez une clé API : <strong style={{ color: 'var(--text)' }}>Mon compte → Clés API → Nouvelle clé</strong> (par exemple nommée « Claude Desktop »).</li>
<li>Allez dans <strong style={{ color: 'var(--text)' }}>Mon compte → Serveur MCP</strong> et renseignez l'URL publique
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>https://mcp.&lt;votre domaine&gt;/mcp</code>) ainsi que la clé générée. L'environnement
se détecte automatiquement sur « PROD » dès que l'URL ne contient ni <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>localhost</code> ni
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> dev</code> — cochez « Forcer manuellement » si votre domaine de test prête à confusion.</li>
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>{mcpConfigUrl}</code>{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.</li>
<li>Copiez la configuration générée dans <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>claude_desktop_config.json</code> (Réglages
Développeur → Serveurs MCP locaux → Modifier la config), exactement comme en développement — seule l'URL change,
le mécanisme <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>mcp-remote</code> (et le wrapper <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>cmd /c</code> sous
Windows) reste identique.</li>
Développeur Serveurs MCP locaux Modifier la config) le mécanisme passe par
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> mcp-remote</code> (avec le wrapper <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>cmd /c</code> sous
Windows), généré automatiquement pour vous.</li>
<li>Redémarrez complètement Claude Desktop.</li>
</ol>
<p>
Vous pouvez connecter dev et prod <strong style={{ color: 'var(--text)' }}>simultanément</strong> : répétez ces étapes une seconde fois
avec l'URL locale et une clé distincte, les deux entrées de config (<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-dev</code> /
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> crowdlending-prod</code>) coexistent sans collision.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Sécurité</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
@@ -223,16 +372,16 @@ export default function Aide() {
<li>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.</li>
<li>L'outil <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> (lecture d'une page web arbitraire) reste
<strong style={{ color: 'var(--text)' }}> désactivé en production</strong>, même s'il est activé chez vous en développement local.</li>
<strong style={{ color: 'var(--text)' }}> désactivé</strong> sur ce serveur.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même qu'en développement local
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>npx</code> 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 :
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> npx</code> qui échoue silencieusement à joindre le registre npm voir la
section Dépannage de <strong style={{ color: 'var(--text)' }}>Mon compte Serveur MCP</strong>.
</p>
</FaqItem>
<FaqItem question="Comment utiliser le serveur MCP au quotidien ? (fonctions disponibles et exemples)">
<FaqItem question="Comment utiliser le serveur MCP au quotidien ? (fonctions disponibles et exemples)" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
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 <strong style={{ color: 'var(--text)' }}>strictement
@@ -309,6 +458,214 @@ export default function Aide() {
</p>
</FaqItem>
<FaqItem question="Comment interroger mon portefeuille depuis Excel avec Power Query ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
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.
</p>
<div>
<DownloadLink href="/powerquery/crowdlending-powerquery-exemple.xlsb">
Télécharger le classeur complet (.xlsb)
</DownloadLink>
</div>
<p style={{ marginTop: 0, marginBottom: 16 }}>
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 <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> individuels
proposés à chaque étape ci-dessous.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 1 Générer une clé API</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>Mon compte Clés API Nouvelle clé</strong>.</li>
<li>Donnez-lui un nom explicite (ex. « Excel »), validez, puis copiez immédiatement la clé
affichée elle ne sera plus visible en clair ensuite.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 2 Créer les deux requêtes de connexion</h4>
<p>
Dans Excel : <strong style={{ color: 'var(--text)' }}>Données Obtenir des données À partir d'autres
sources → Requête vide</strong>. Une nouvelle requête « Requête1 » apparaît dans l'éditeur Power Query.
</p>
<ol style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Renommez-la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> (double-clic
sur son nom dans le volet Requêtes), puis <strong style={{ color: 'var(--text)' }}>Affichage Éditeur avancé</strong>,
effacez le contenu par défaut et collez :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"http://localhost:4000/api/v1"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiBaseUrl.pq">Télécharger ApiBaseUrl.pq</DownloadLink>
<img
src="/powerquery/apibaseurl-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiBaseUrl avec l'URL de l'API en production"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<ol start={2} style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Cliquez <strong style={{ color: 'var(--text)' }}>Terminé</strong>, puis <strong style={{ color: 'var(--text)' }}>Accueil Fermer et charger dans Uniquement créer la connexion</strong> (pas besoin d'un tableau pour celle-ci).</li>
<li>Répétez l'opération pour une seconde requête vide nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code> :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"clk_live_VOTRE_CLE_API"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiKey.pq">Télécharger ApiKey.pq</DownloadLink>
<img
src="/powerquery/apikey-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiKey avec la clé API renseignée"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Remplacez par la clé copiée à l'étape 1, entre guillemets. Même chose : Terminé → Uniquement créer la connexion.</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 3 — La fonction technique fnApiGet</h4>
<p>
Une troisième requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnApiGet</code>,
fait l'appel HTTP et transmet la clé dans l'en-tête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>X-API-Key</code> —
les requêtes suivantes la réutiliseront par son nom, sans la récrire :
</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`(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`}
</pre>
<DownloadLink href="/powerquery/queries/fnApiGet.pq">Télécharger fnApiGet.pq</DownloadLink>
<img
src="/powerquery/fnapiget-editeur-avance.png"
alt="Éditeur avancé Power Query — fonction fnApiGet"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Terminé → Fermer et charger dans… → Uniquement créer la connexion (ce n'est pas une donnée à afficher).</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 4 Première requête de données : Investissements</h4>
<p>Une nouvelle requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investissements</code> :</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`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`}
</pre>
<DownloadLink href="/powerquery/queries/Investissements.pq">Télécharger Investissements.pq</DownloadLink>
<img
src="/powerquery/investissements-editeur-avance.png"
alt="Éditeur avancé Power Query — requête Investissements avec aperçu des données chargées"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>
Cette fois-ci, <strong style={{ color: 'var(--text)' }}>Terminé Fermer et charger</strong> (le bouton
simple, pas « ») : la requête s'ajoute comme tableau dans une nouvelle feuille, avec vos
investissements en ligne et en colonnes.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 5 — Actualiser</h4>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: 'var(--text)' }}>Données → Actualiser tout</strong> (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.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Aller plus loin — les autres requêtes</h4>
<p>Même principe (requête vide → nom exact → éditeur avancé → coller → charger) pour :</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investisseur</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil investisseur (ou liste des membres si clé « Famille et entreprises »).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Investisseur.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Dashboard.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements, filtrable par période.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Remboursements.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>DépôtsRetraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/DepotsRetraits.pq">.pq</DownloadLink>
</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnDetailInvestissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
Fonction avancée : sur la table <em>Investissements</em>, colonne personnalisée{' '}
<code style={{ fontFamily: 'monospace' }}>= fnDetailInvestissement([id])</code>{' '}
pour ramener le détail + les remboursements de chaque prêt.
</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/fnDetailInvestissement.pq">.pq</DownloadLink>
</td>
</tr>
</tbody>
</table>
<p>
Toutes ces requêtes sont déjà incluses dans le classeur téléchargeable en haut de cette fiche —
les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> ci-dessus
ne sont utiles que si vous préférez les coller vous-même dans un classeur existant.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Dépannage</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>« Impossible de se connecter au service distant »</strong> — le
backend n'est pas démarré, ou <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> est
incorrecte (vérifiez le port et le suffixe <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>/api/v1</code>).</li>
<li><strong style={{ color: 'var(--text)' }}>Erreur 401 / clé invalide</strong> régénérez une clé dans
Mon compte Clés API et mettez à jour la requête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code>.</li>
<li><strong style={{ color: 'var(--text)' }}>Avertissement de confidentialité au premier chargement</strong>
normal pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou « Public »
selon votre contexte.</li>
</ul>
<p style={{ marginBottom: 0 }}>
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.
</p>
</FaqItem>
</div>
)}
+17 -1
View File
@@ -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-<nom>.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.<hostname> 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.<domaine de prod> 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 }) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
<div>
<label>URL du serveur MCP</label>
<input value={mcpUrl} onChange={e => setMcpUrl(e.target.value)} />
<input value={mcpUrl} onChange={e => { mcpUrlEditedRef.current = true; setMcpUrl(e.target.value); }} />
</div>
<div>
<label>Clé API</label>
+29 -7
View File
@@ -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() {
/>
</SettingRow>
<SettingRow label="URL de la plateforme" description="Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).">
<SettingRow label="URL de la plateforme" description={`Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
@@ -114,6 +124,18 @@ export default function GeneralSection() {
style={{ width: '100%' }}
/>
</SettingRow>
<SettingRow label="URL du serveur MCP" description={`Point d'entrée public du serveur MCP (endpoint /mcp inclus). Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
maxLength={500}
value={form.mcpUrl}
placeholder="https://mcp.mon-app.example.com/mcp"
onChange={e => set('mcpUrl', e.target.value)}
style={{ width: '100%' }}
/>
</SettingRow>
</div>
{/* Accès */}
+27
View File
@@ -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 };
}