stats v3 : rapports PDF personnalisés (catalogue, rendu au choix, ReportBuilder)
- moteur pdfkit local src/lib/report-stats.ts (gabarit Groupe-KA, palette lime/vert du hub) : catalogFromDashboard() sur EcoPayload + buildCustomReport() — primitives line/area/bar/donut/hbars/stacked/multi/gauges/kpi/heatmap quotidienne ET horaire/tables/records, sommaire, couverture à libellé libre - routes GET /api/stats/catalog et POST /api/stats/report/custom (filename groupe-ka_groupe-ka_stats_<periode>_personnalise_<date>.pdf) ; rapport écosystème v2 (proxy API-KA) intact - kit src/ka/stats (kacharts.tsx v3 avec ReportBuilder dans PdfButton, SPEC.md §3bis, kapdf.py) synchronisé depuis /tmp/ka-stats-v3 - pdfkit ^0.19.1 (+types) en serverExternalPackages, polices du gabarit sous assets/fonts/
13 changed files +1,844 −11
added
assets/fonts/Inter-Regular.ttf
+0 −0
Binary file not shown.
added
assets/fonts/JetBrainsMono-Bold.ttf
+0 −0
Binary file not shown.
added
assets/fonts/JetBrainsMono-Regular.ttf
+0 −0
Binary file not shown.
added
assets/fonts/SpaceGrotesk-Bold.ttf
+0 −0
Binary file not shown.
modified
next.config.ts
+1 −1
@@ -2,7 +2,7 @@ | ||
| 2 | 2 | import type { NextConfig } from "next"; |
| 3 | 3 | |
| 4 | 4 | const nextConfig: NextConfig = { |
| 5 | − serverExternalPackages: ["better-sqlite3", "sharp"], | |
| 5 | + serverExternalPackages: ["better-sqlite3", "sharp", "pdfkit"], | |
| 6 | 6 | }; |
| 7 | 7 | |
| 8 | 8 | export default nextConfig; |
modified
package-lock.json
+203 −0
@@ -11,6 +11,7 @@ | ||
| 11 | 11 | "better-sqlite3": "^13.0.3", |
| 12 | 12 | "jose": "^6.2.8", |
| 13 | 13 | "next": "16.3.0", |
| 14 | + "pdfkit": "^0.19.1", | |
| 14 | 15 | "qrcode": "^1.5.4", |
| 15 | 16 | "react": "19.2.8", |
| 16 | 17 | "react-dom": "19.2.8", |
@@ -20,6 +21,7 @@ | ||
| 20 | 21 | "@tailwindcss/postcss": "^4", |
| 21 | 22 | "@types/better-sqlite3": "^9.6.0", |
| 22 | 23 | "@types/node": "^20", |
| 24 | + "@types/pdfkit": "^0.17.6", | |
| 23 | 25 | "@types/qrcode": "^1.5.6", |
| 24 | 26 | "@types/react": "^19", |
| 25 | 27 | "@types/react-dom": "^19", |
@@ -794,6 +796,30 @@ | ||
| 794 | 796 | "node": ">= 10" |
| 795 | 797 | } |
| 796 | 798 | }, |
| 799 | + "node_modules/@noble/ciphers": { | |
| 800 | + "version": "1.3.0", | |
| 801 | + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", | |
| 802 | + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", | |
| 803 | + "license": "MIT", | |
| 804 | + "engines": { | |
| 805 | + "node": "^14.21.3 || >=16" | |
| 806 | + }, | |
| 807 | + "funding": { | |
| 808 | + "url": "https://paulmillr.com/funding/" | |
| 809 | + } | |
| 810 | + }, | |
| 811 | + "node_modules/@noble/hashes": { | |
| 812 | + "version": "1.8.0", | |
| 813 | + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", | |
| 814 | + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", | |
| 815 | + "license": "MIT", | |
| 816 | + "engines": { | |
| 817 | + "node": "^14.21.3 || >=16" | |
| 818 | + }, | |
| 819 | + "funding": { | |
| 820 | + "url": "https://paulmillr.com/funding/" | |
| 821 | + } | |
| 822 | + }, | |
| 797 | 823 | "node_modules/@swc/helpers": { |
| 798 | 824 | "version": "0.5.15", |
| 799 | 825 | "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", |
@@ -1106,6 +1132,16 @@ | ||
| 1106 | 1132 | "undici-types": "~6.21.0" |
| 1107 | 1133 | } |
| 1108 | 1134 | }, |
| 1135 | + "node_modules/@types/pdfkit": { | |
| 1136 | + "version": "0.17.6", | |
| 1137 | + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", | |
| 1138 | + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", | |
| 1139 | + "dev": true, | |
| 1140 | + "license": "MIT", | |
| 1141 | + "dependencies": { | |
| 1142 | + "@types/node": "*" | |
| 1143 | + } | |
| 1144 | + }, | |
| 1109 | 1145 | "node_modules/@types/qrcode": { |
| 1110 | 1146 | "version": "1.5.6", |
| 1111 | 1147 | "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", |
@@ -1160,6 +1196,26 @@ | ||
| 1160 | 1196 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" |
| 1161 | 1197 | } |
| 1162 | 1198 | }, |
| 1199 | + "node_modules/base64-js": { | |
| 1200 | + "version": "1.5.1", | |
| 1201 | + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", | |
| 1202 | + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", | |
| 1203 | + "funding": [ | |
| 1204 | + { | |
| 1205 | + "type": "github", | |
| 1206 | + "url": "https://github.com/sponsors/feross" | |
| 1207 | + }, | |
| 1208 | + { | |
| 1209 | + "type": "patreon", | |
| 1210 | + "url": "https://www.patreon.com/feross" | |
| 1211 | + }, | |
| 1212 | + { | |
| 1213 | + "type": "consulting", | |
| 1214 | + "url": "https://feross.org/support" | |
| 1215 | + } | |
| 1216 | + ], | |
| 1217 | + "license": "MIT" | |
| 1218 | + }, | |
| 1163 | 1219 | "node_modules/baseline-browser-mapping": { |
| 1164 | 1220 | "version": "2.11.13", |
| 1165 | 1221 | "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", |
@@ -1184,6 +1240,24 @@ | ||
| 1184 | 1240 | "node": ">=22" |
| 1185 | 1241 | } |
| 1186 | 1242 | }, |
| 1243 | + "node_modules/brotli": { | |
| 1244 | + "version": "1.3.3", | |
| 1245 | + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", | |
| 1246 | + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", | |
| 1247 | + "license": "MIT", | |
| 1248 | + "dependencies": { | |
| 1249 | + "base64-js": "^1.1.2" | |
| 1250 | + } | |
| 1251 | + }, | |
| 1252 | + "node_modules/browserify-zlib": { | |
| 1253 | + "version": "0.2.0", | |
| 1254 | + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", | |
| 1255 | + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", | |
| 1256 | + "license": "MIT", | |
| 1257 | + "dependencies": { | |
| 1258 | + "pako": "~1.0.5" | |
| 1259 | + } | |
| 1260 | + }, | |
| 1187 | 1261 | "node_modules/camelcase": { |
| 1188 | 1262 | "version": "5.3.1", |
| 1189 | 1263 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", |
@@ -1230,6 +1304,15 @@ | ||
| 1230 | 1304 | "wrap-ansi": "^6.2.0" |
| 1231 | 1305 | } |
| 1232 | 1306 | }, |
| 1307 | + "node_modules/clone": { | |
| 1308 | + "version": "2.1.2", | |
| 1309 | + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", | |
| 1310 | + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", | |
| 1311 | + "license": "MIT", | |
| 1312 | + "engines": { | |
| 1313 | + "node": ">=0.8" | |
| 1314 | + } | |
| 1315 | + }, | |
| 1233 | 1316 | "node_modules/color-convert": { |
| 1234 | 1317 | "version": "2.0.1", |
| 1235 | 1318 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", |
@@ -1273,6 +1356,12 @@ | ||
| 1273 | 1356 | "node": ">=8" |
| 1274 | 1357 | } |
| 1275 | 1358 | }, |
| 1359 | + "node_modules/dfa": { | |
| 1360 | + "version": "1.2.0", | |
| 1361 | + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", | |
| 1362 | + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", | |
| 1363 | + "license": "MIT" | |
| 1364 | + }, | |
| 1276 | 1365 | "node_modules/dijkstrajs": { |
| 1277 | 1366 | "version": "1.0.3", |
| 1278 | 1367 | "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", |
@@ -1299,6 +1388,12 @@ | ||
| 1299 | 1388 | "node": ">=10.13.0" |
| 1300 | 1389 | } |
| 1301 | 1390 | }, |
| 1391 | + "node_modules/fast-deep-equal": { | |
| 1392 | + "version": "3.1.3", | |
| 1393 | + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | |
| 1394 | + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", | |
| 1395 | + "license": "MIT" | |
| 1396 | + }, | |
| 1302 | 1397 | "node_modules/find-up": { |
| 1303 | 1398 | "version": "4.1.0", |
| 1304 | 1399 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", |
@@ -1312,6 +1407,23 @@ | ||
| 1312 | 1407 | "node": ">=8" |
| 1313 | 1408 | } |
| 1314 | 1409 | }, |
| 1410 | + "node_modules/fontkit": { | |
| 1411 | + "version": "2.0.4", | |
| 1412 | + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", | |
| 1413 | + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", | |
| 1414 | + "license": "MIT", | |
| 1415 | + "dependencies": { | |
| 1416 | + "@swc/helpers": "^0.5.12", | |
| 1417 | + "brotli": "^1.3.2", | |
| 1418 | + "clone": "^2.1.2", | |
| 1419 | + "dfa": "^1.2.0", | |
| 1420 | + "fast-deep-equal": "^3.1.3", | |
| 1421 | + "restructure": "^3.0.0", | |
| 1422 | + "tiny-inflate": "^1.0.3", | |
| 1423 | + "unicode-properties": "^1.4.0", | |
| 1424 | + "unicode-trie": "^2.0.0" | |
| 1425 | + } | |
| 1426 | + }, | |
| 1315 | 1427 | "node_modules/get-caller-file": { |
| 1316 | 1428 | "version": "2.0.5", |
| 1317 | 1429 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", |
@@ -1356,6 +1468,12 @@ | ||
| 1356 | 1468 | "url": "https://github.com/sponsors/panva" |
| 1357 | 1469 | } |
| 1358 | 1470 | }, |
| 1471 | + "node_modules/js-md5": { | |
| 1472 | + "version": "0.8.3", | |
| 1473 | + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", | |
| 1474 | + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", | |
| 1475 | + "license": "MIT" | |
| 1476 | + }, | |
| 1359 | 1477 | "node_modules/lightningcss": { |
| 1360 | 1478 | "version": "1.32.0", |
| 1361 | 1479 | "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", |
@@ -1629,6 +1747,25 @@ | ||
| 1629 | 1747 | "url": "https://opencollective.com/parcel" |
| 1630 | 1748 | } |
| 1631 | 1749 | }, |
| 1750 | + "node_modules/linebreak": { | |
| 1751 | + "version": "1.1.0", | |
| 1752 | + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", | |
| 1753 | + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", | |
| 1754 | + "license": "MIT", | |
| 1755 | + "dependencies": { | |
| 1756 | + "base64-js": "0.0.8", | |
| 1757 | + "unicode-trie": "^2.0.0" | |
| 1758 | + } | |
| 1759 | + }, | |
| 1760 | + "node_modules/linebreak/node_modules/base64-js": { | |
| 1761 | + "version": "0.0.8", | |
| 1762 | + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", | |
| 1763 | + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", | |
| 1764 | + "license": "MIT", | |
| 1765 | + "engines": { | |
| 1766 | + "node": ">= 0.4" | |
| 1767 | + } | |
| 1768 | + }, | |
| 1632 | 1769 | "node_modules/locate-path": { |
| 1633 | 1770 | "version": "5.0.0", |
| 1634 | 1771 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", |
@@ -1795,6 +1932,12 @@ | ||
| 1795 | 1932 | "node": ">=6" |
| 1796 | 1933 | } |
| 1797 | 1934 | }, |
| 1935 | + "node_modules/pako": { | |
| 1936 | + "version": "1.0.11", | |
| 1937 | + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", | |
| 1938 | + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", | |
| 1939 | + "license": "(MIT AND Zlib)" | |
| 1940 | + }, | |
| 1798 | 1941 | "node_modules/path-exists": { |
| 1799 | 1942 | "version": "4.0.0", |
| 1800 | 1943 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", |
@@ -1804,12 +1947,34 @@ | ||
| 1804 | 1947 | "node": ">=8" |
| 1805 | 1948 | } |
| 1806 | 1949 | }, |
| 1950 | + "node_modules/pdfkit": { | |
| 1951 | + "version": "0.19.1", | |
| 1952 | + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", | |
| 1953 | + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", | |
| 1954 | + "license": "MIT", | |
| 1955 | + "dependencies": { | |
| 1956 | + "@noble/ciphers": "^1.0.0", | |
| 1957 | + "@noble/hashes": "^1.6.0", | |
| 1958 | + "fontkit": "^2.0.4", | |
| 1959 | + "js-md5": "^0.8.3", | |
| 1960 | + "linebreak": "^1.1.0", | |
| 1961 | + "png-js": "^1.1.0" | |
| 1962 | + } | |
| 1963 | + }, | |
| 1807 | 1964 | "node_modules/picocolors": { |
| 1808 | 1965 | "version": "1.1.1", |
| 1809 | 1966 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", |
| 1810 | 1967 | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", |
| 1811 | 1968 | "license": "ISC" |
| 1812 | 1969 | }, |
| 1970 | + "node_modules/png-js": { | |
| 1971 | + "version": "1.1.0", | |
| 1972 | + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", | |
| 1973 | + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", | |
| 1974 | + "dependencies": { | |
| 1975 | + "browserify-zlib": "^0.2.0" | |
| 1976 | + } | |
| 1977 | + }, | |
| 1813 | 1978 | "node_modules/pngjs": { |
| 1814 | 1979 | "version": "5.0.0", |
| 1815 | 1980 | "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", |
@@ -1901,6 +2066,12 @@ | ||
| 1901 | 2066 | "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", |
| 1902 | 2067 | "license": "ISC" |
| 1903 | 2068 | }, |
| 2069 | + "node_modules/restructure": { | |
| 2070 | + "version": "3.0.2", | |
| 2071 | + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", | |
| 2072 | + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", | |
| 2073 | + "license": "MIT" | |
| 2074 | + }, | |
| 1904 | 2075 | "node_modules/scheduler": { |
| 1905 | 2076 | "version": "0.27.0", |
| 1906 | 2077 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", |
@@ -2053,6 +2224,12 @@ | ||
| 2053 | 2224 | "url": "https://opencollective.com/webpack" |
| 2054 | 2225 | } |
| 2055 | 2226 | }, |
| 2227 | + "node_modules/tiny-inflate": { | |
| 2228 | + "version": "1.0.3", | |
| 2229 | + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", | |
| 2230 | + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", | |
| 2231 | + "license": "MIT" | |
| 2232 | + }, | |
| 2056 | 2233 | "node_modules/tslib": { |
| 2057 | 2234 | "version": "2.8.1", |
| 2058 | 2235 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", |
@@ -2080,6 +2257,32 @@ | ||
| 2080 | 2257 | "dev": true, |
| 2081 | 2258 | "license": "MIT" |
| 2082 | 2259 | }, |
| 2260 | + "node_modules/unicode-properties": { | |
| 2261 | + "version": "1.4.1", | |
| 2262 | + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", | |
| 2263 | + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", | |
| 2264 | + "license": "MIT", | |
| 2265 | + "dependencies": { | |
| 2266 | + "base64-js": "^1.3.0", | |
| 2267 | + "unicode-trie": "^2.0.0" | |
| 2268 | + } | |
| 2269 | + }, | |
| 2270 | + "node_modules/unicode-trie": { | |
| 2271 | + "version": "2.0.0", | |
| 2272 | + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", | |
| 2273 | + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", | |
| 2274 | + "license": "MIT", | |
| 2275 | + "dependencies": { | |
| 2276 | + "pako": "^0.2.5", | |
| 2277 | + "tiny-inflate": "^1.0.0" | |
| 2278 | + } | |
| 2279 | + }, | |
| 2280 | + "node_modules/unicode-trie/node_modules/pako": { | |
| 2281 | + "version": "0.2.9", | |
| 2282 | + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", | |
| 2283 | + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", | |
| 2284 | + "license": "MIT" | |
| 2285 | + }, | |
| 2083 | 2286 | "node_modules/which-module": { |
| 2084 | 2287 | "version": "2.0.1", |
| 2085 | 2288 | "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", |
modified
package.json
+2 −0
@@ -13,6 +13,7 @@ | ||
| 13 | 13 | "better-sqlite3": "^13.0.3", |
| 14 | 14 | "jose": "^6.2.8", |
| 15 | 15 | "next": "16.3.0", |
| 16 | + "pdfkit": "^0.19.1", | |
| 16 | 17 | "qrcode": "^1.5.4", |
| 17 | 18 | "react": "19.2.8", |
| 18 | 19 | "react-dom": "19.2.8", |
@@ -22,6 +23,7 @@ | ||
| 22 | 23 | "@tailwindcss/postcss": "^4", |
| 23 | 24 | "@types/better-sqlite3": "^9.6.0", |
| 24 | 25 | "@types/node": "^20", |
| 26 | + "@types/pdfkit": "^0.17.6", | |
| 25 | 27 | "@types/qrcode": "^1.5.6", |
| 26 | 28 | "@types/react": "^19", |
| 27 | 29 | "@types/react-dom": "^19", |
added
src/app/api/stats/catalog/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * GET /api/stats/catalog — v3 : blocs composables pour le constructeur de | |
| 4 | + * rapports personnalisés (src/ka/stats/SPEC.md §3bis). Dérivé de l'EcoPayload | |
| 5 | + * consolidé (src/lib/ecostats.ts — mêmes chiffres que la page /stats, cache | |
| 6 | + * amont 10 min par plateforme) ; `period` accepté, `from`/`to` non | |
| 7 | + * applicables (les dashboards amont ne parlent que par période). | |
| 8 | + */ | |
| 9 | +import { type NextRequest, NextResponse } from "next/server"; | |
| 10 | +import { catalogFromDashboard, getReportPayload } from "@/lib/report-stats"; | |
| 11 | + | |
| 12 | +export const dynamic = "force-dynamic"; | |
| 13 | + | |
| 14 | +export async function GET(req: NextRequest) { | |
| 15 | + const payload = await getReportPayload(req.nextUrl.searchParams.get("period") ?? undefined); | |
| 16 | + return NextResponse.json( | |
| 17 | + { updated: payload.freshness ?? null, | |
| 18 | + period: { label: payload.periodLabel }, | |
| 19 | + blocks: catalogFromDashboard(payload) }, | |
| 20 | + { headers: { "Cache-Control": "public, max-age=300" } }, | |
| 21 | + ); | |
| 22 | +} | |
added
src/app/api/stats/report/custom/route.ts
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * POST /api/stats/report/custom — v3 : rapport PDF personnalisé | |
| 4 | + * (src/ka/stats/SPEC.md §3bis). Corps : {"title", "period", "from", "to", | |
| 5 | + * "blocks": [{"key": "series:…", "render": "bar"}, …]} — ordre respecté, | |
| 6 | + * clés inconnues ignorées, aucune clé valide → 400. Généré LOCALEMENT | |
| 7 | + * depuis l'EcoPayload consolidé (le rapport écosystème v2 à 5 modes reste, | |
| 8 | + * lui, proxifié vers API-KA via /api/stats/ecosystem-report). | |
| 9 | + */ | |
| 10 | +import { type NextRequest, NextResponse } from "next/server"; | |
| 11 | +import { | |
| 12 | + buildCustomReport, | |
| 13 | + customReportFilename, | |
| 14 | + getReportPayload, | |
| 15 | + type CustomSpec, | |
| 16 | +} from "@/lib/report-stats"; | |
| 17 | +import { isPeriod } from "@/lib/ecostats"; | |
| 18 | + | |
| 19 | +export const dynamic = "force-dynamic"; | |
| 20 | + | |
| 21 | +export async function POST(req: NextRequest) { | |
| 22 | + let spec: CustomSpec; | |
| 23 | + try { | |
| 24 | + spec = (await req.json()) as CustomSpec; | |
| 25 | + } catch { | |
| 26 | + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); | |
| 27 | + } | |
| 28 | + const period = isPeriod(spec?.period) ? spec.period : "30j"; | |
| 29 | + const payload = await getReportPayload(period); | |
| 30 | + try { | |
| 31 | + const pdf = await buildCustomReport(spec ?? {}, payload); | |
| 32 | + return new NextResponse(new Uint8Array(pdf), { | |
| 33 | + headers: { | |
| 34 | + "Content-Type": "application/pdf", | |
| 35 | + "Content-Disposition": `attachment; filename="${customReportFilename(period)}"`, | |
| 36 | + "Cache-Control": "no-store", | |
| 37 | + }, | |
| 38 | + }); | |
| 39 | + } catch (e) { | |
| 40 | + if (e instanceof Error && e.message === "aucun-bloc") { | |
| 41 | + return NextResponse.json({ error: "Aucun bloc valide dans la composition" }, { status: 400 }); | |
| 42 | + } | |
| 43 | + throw e; | |
| 44 | + } | |
| 45 | +} | |
modified
src/ka/stats/SPEC.md
+62 −1
@@ -1,4 +1,4 @@ | ||
| 1 | −# ka-stats — module Stats commun Groupe KA (spec v2) | |
| 1 | +# ka-stats — module Stats commun Groupe KA (spec v3) | |
| 2 | 2 | |
| 3 | 3 | Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de |
| 4 | 4 | bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit |
@@ -10,6 +10,15 @@ sur les répartitions, statistiques de séries (min/max/moy/méd/σ), et **5 | ||
| 10 | 10 | rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** : |
| 11 | 11 | un dashboard v1 reste valide et se rend tel quel. |
| 12 | 12 | |
| 13 | +**v3 (2026-08-23)** : **rapports personnalisés** — l'utilisateur compose son | |
| 14 | +propre rapport PDF bloc par bloc : choix des données (catalogue dérivé du | |
| 15 | +dashboard), du **rendu par bloc** (courbe/aire/barres/anneau/histogramme/ | |
| 16 | +heatmap/tableau…), de l'ordre, avec **modèles sauvegardés** (localStorage du | |
| 17 | +site). Deux endpoints (`/api/stats/catalog`, `POST /api/stats/report/custom`, | |
| 18 | +voir §3bis) + un constructeur dans la page /stats (`ReportBuilder` du kit — | |
| 19 | +bouton « 🛠 Rapport personnalisé » à côté du menu PDF). Le PDF garde le | |
| 20 | +gabarit estampillé Groupe-KA avec l'accent de la plateforme. v2 inchangée. | |
| 21 | + | |
| 13 | 22 | ## 1. Page /stats — structure obligatoire (dans cet ordre) |
| 14 | 23 | |
| 15 | 24 | 1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé, |
@@ -135,6 +144,58 @@ gabarit en pdfkit) : | ||
| 135 | 144 | - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du |
| 136 | 145 | DS si présentes. |
| 137 | 146 | |
| 147 | +## 3bis. Rapports personnalisés (v3) | |
| 148 | + | |
| 149 | +### Catalogue | |
| 150 | + | |
| 151 | +`GET /api/stats/catalog?period=…&from=&to=` → | |
| 152 | + | |
| 153 | +```jsonc | |
| 154 | +{ "updated": "…", "period": { … }, | |
| 155 | + "blocks": [ { "key": "series:ajouts", // section[:id] — clé stable | |
| 156 | + "section": "series", | |
| 157 | + "title": "Événements ajoutés par jour", | |
| 158 | + "renders": ["line","area","bar","table"], // rendus compatibles | |
| 159 | + "default_render": "line", | |
| 160 | + "count": 30 } ] } // taille indicative (optionnel) | |
| 161 | +``` | |
| 162 | + | |
| 163 | +Sections → rendus : `kpis` cards|table · `gauges` gauges|table · | |
| 164 | +`series:<id>` line|area|bar|table · `multiseries:<id>` lines|table · | |
| 165 | +`stacked:<id>` stacked|table · `breakdowns:<id>` donut|bars|table · | |
| 166 | +`distributions:<id>` histogram|table · `geo` bars|table · | |
| 167 | +`heatmap` heatmap|table · `hourly` heatmap|table · `tables:<id>` table · | |
| 168 | +`records` cards|table. **Toute donnée a un équivalent tableau.** Le catalogue | |
| 169 | +est dérivé du dashboard (implémentation : `kapdf.catalog(dash)` / | |
| 170 | +`catalogFromDashboard()` en TS) — zéro maintenance quand une métrique s'ajoute. | |
| 171 | + | |
| 172 | +### Génération | |
| 173 | + | |
| 174 | +`POST /api/stats/report/custom` — corps JSON : | |
| 175 | + | |
| 176 | +```jsonc | |
| 177 | +{ "title": "Revue mensuelle", // ≤ 80 car., affiché en couverture | |
| 178 | + "period": "30j", "from": "", "to": "", // mêmes règles que le dashboard | |
| 179 | + "blocks": [ { "key": "kpis", "render": "cards" }, | |
| 180 | + { "key": "series:ajouts", "render": "bar" } ] } // ordre = ordre du PDF | |
| 181 | +``` | |
| 182 | + | |
| 183 | +→ `application/pdf`, filename `groupe-ka_<plateforme>_stats_<periode>_personnalise_<date>.pdf`. | |
| 184 | +Clés inconnues ignorées ; rendu incompatible → rendu par défaut ; aucun bloc | |
| 185 | +valide → **400**. Maximum 40 blocs. Couverture : type = « Rapport | |
| 186 | +personnalisé — {title} » ; sommaire ; page de fin habituelle. Un même bloc | |
| 187 | +peut apparaître plusieurs fois (ex. graphique + tableau). | |
| 188 | + | |
| 189 | +### Constructeur (front, kit) | |
| 190 | + | |
| 191 | +`ReportBuilder` (kacharts.tsx ; port vanilla pour les SPA sans React) : | |
| 192 | +panneau modal 2 colonnes — catalogue groupé par section à gauche, composition | |
| 193 | +ordonnée à droite (↑ ↓ ✕, sélecteur de rendu par bloc, titre). **Modèles** : | |
| 194 | +sauvegarde/chargement/suppression nommés en localStorage (clé | |
| 195 | +`ka-stats-rapports`, propre à l'origine du site). Bouton « Générer le PDF » | |
| 196 | +→ POST + téléchargement blob. États busy/erreur propres, tactile ≥ 44 px, | |
| 197 | +z-index `var(--z-modal, 900)`. | |
| 198 | + | |
| 138 | 199 | ## 4. Spécifique par plateforme (sections métier attendues) |
| 139 | 200 | |
| 140 | 201 | - **groupe-ka** : tableau de bord maître — consolidation des plateformes |
modified
src/ka/stats/kacharts.tsx
+213 −1
@@ -676,10 +676,11 @@ export function RecordCard({ r }: { r: RecordFact }) { | ||
| 676 | 676 | ); |
| 677 | 677 | } |
| 678 | 678 | |
| 679 | −/* ---------- Menu de rapports PDF (5 rapports) ---------- */ | |
| 679 | +/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */ | |
| 680 | 680 | export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { |
| 681 | 681 | const [open, setOpen] = useState(false); |
| 682 | 682 | const [busy, setBusy] = useState<string | null>(null); |
| 683 | + const [builder, setBuilder] = useState(false); | |
| 683 | 684 | const box = useRef<HTMLSpanElement>(null); |
| 684 | 685 | useEffect(() => { |
| 685 | 686 | if (!open) return; |
@@ -715,6 +716,13 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: | ||
| 715 | 716 | aria-haspopup="menu" aria-expanded={open}> |
| 716 | 717 | Autres rapports ▾ |
| 717 | 718 | </button> |
| 719 | + <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}> | |
| 720 | + 🛠 Rapport personnalisé | |
| 721 | + </button> | |
| 722 | + {builder && ( | |
| 723 | + <ReportBuilder period={period} from={from} to={to} endpoint={endpoint} | |
| 724 | + onClose={() => setBuilder(false)} /> | |
| 725 | + )} | |
| 718 | 726 | {open && ( |
| 719 | 727 | <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}> |
| 720 | 728 | {REPORT_MODES.map((m) => ( |
@@ -732,6 +740,210 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: | ||
| 732 | 740 | ); |
| 733 | 741 | } |
| 734 | 742 | |
| 743 | +/* ---------- v3 : constructeur de rapports personnalisés ---------- | |
| 744 | + Compose un PDF bloc par bloc : catalogue dérivé du dashboard | |
| 745 | + (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles | |
| 746 | + sauvegardés en localStorage (clé ka-stats-rapports, propre au site). | |
| 747 | + Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */ | |
| 748 | +export type CatalogBlock = { | |
| 749 | + key: string; section: string; title: string; | |
| 750 | + renders: string[]; default_render: string; count?: number; | |
| 751 | +}; | |
| 752 | +type BuilderSel = { key: string; render: string }; | |
| 753 | +type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] }; | |
| 754 | + | |
| 755 | +const RENDER_LABELS: Record<string, string> = { | |
| 756 | + line: "Courbe", area: "Aire", bar: "Barres verticales", | |
| 757 | + bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes", | |
| 758 | + stacked: "Barres empilées", histogram: "Histogramme", heatmap: "Heatmap", | |
| 759 | + cards: "Cartes", gauges: "Jauges", table: "Tableau", | |
| 760 | +}; | |
| 761 | +const SECTION_LABELS: Record<string, string> = { | |
| 762 | + kpis: "Indicateurs", gauges: "Jauges", series: "Évolution", | |
| 763 | + multiseries: "Multi-courbes", stacked: "Compositions", | |
| 764 | + breakdowns: "Répartitions", distributions: "Distributions", | |
| 765 | + geo: "Géographie", heatmap: "Calendrier", hourly: "Activité horaire", | |
| 766 | + tables: "Tableaux", records: "Records", | |
| 767 | +}; | |
| 768 | +const TPL_KEY = "ka-stats-rapports"; | |
| 769 | + | |
| 770 | +function loadTemplates(): BuilderTpl[] { | |
| 771 | + try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); } | |
| 772 | + catch { return []; } | |
| 773 | +} | |
| 774 | +function saveTemplates(t: BuilderTpl[]) { | |
| 775 | + try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ } | |
| 776 | +} | |
| 777 | + | |
| 778 | +export function ReportBuilder({ | |
| 779 | + period, from, to, endpoint = "/api/stats/report", onClose, | |
| 780 | +}: { | |
| 781 | + period: string; from?: string; to?: string; endpoint?: string; onClose: () => void; | |
| 782 | +}) { | |
| 783 | + const [cat, setCat] = useState<CatalogBlock[] | null>(null); | |
| 784 | + const [err, setErr] = useState(""); | |
| 785 | + const [sel, setSel] = useState<BuilderSel[]>([]); | |
| 786 | + const [title, setTitle] = useState(""); | |
| 787 | + const [busy, setBusy] = useState(false); | |
| 788 | + const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates); | |
| 789 | + const catalogUrl = endpoint.replace(/\/report$/, "/catalog"); | |
| 790 | + | |
| 791 | + useEffect(() => { | |
| 792 | + const p = new URLSearchParams({ period }); | |
| 793 | + if (from) p.set("from", from); | |
| 794 | + if (to) p.set("to", to); | |
| 795 | + fetch(`${catalogUrl}?${p}`) | |
| 796 | + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) | |
| 797 | + .then((d) => setCat(d.blocks ?? [])) | |
| 798 | + .catch(() => setErr("Catalogue indisponible — réessayez plus tard.")); | |
| 799 | + }, [period, from, to, catalogUrl]); | |
| 800 | + | |
| 801 | + useEffect(() => { | |
| 802 | + const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; | |
| 803 | + document.addEventListener("keydown", esc); | |
| 804 | + const prev = document.body.style.overflow; | |
| 805 | + document.body.style.overflow = "hidden"; | |
| 806 | + return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; }; | |
| 807 | + }, [onClose]); | |
| 808 | + | |
| 809 | + const add = (b: CatalogBlock) => | |
| 810 | + setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render) | |
| 811 | + ? s : [...s, { key: b.key, render: b.default_render }]); | |
| 812 | + const move = (i: number, d: number) => setSel((s) => { | |
| 813 | + const j = i + d; | |
| 814 | + if (j < 0 || j >= s.length) return s; | |
| 815 | + const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n; | |
| 816 | + }); | |
| 817 | + | |
| 818 | + const generate = async () => { | |
| 819 | + if (busy || !sel.length) return; | |
| 820 | + setBusy(true); setErr(""); | |
| 821 | + try { | |
| 822 | + const body: Record<string, unknown> = { title, period, blocks: sel }; | |
| 823 | + if (from && to) { body.from = from; body.to = to; } | |
| 824 | + const r = await fetch(`${endpoint}/custom`, { | |
| 825 | + method: "POST", headers: { "Content-Type": "application/json" }, | |
| 826 | + body: JSON.stringify(body), | |
| 827 | + }); | |
| 828 | + if (!r.ok) throw new Error(String(r.status)); | |
| 829 | + const blob = await r.blob(); | |
| 830 | + const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/); | |
| 831 | + const a = document.createElement("a"); | |
| 832 | + a.href = URL.createObjectURL(blob); | |
| 833 | + a.download = m ? m[1] : "rapport-personnalise.pdf"; | |
| 834 | + document.body.appendChild(a); a.click(); a.remove(); | |
| 835 | + setTimeout(() => URL.revokeObjectURL(a.href), 4000); | |
| 836 | + } catch { | |
| 837 | + setErr("La génération a échoué — réessayez."); | |
| 838 | + } | |
| 839 | + setBusy(false); | |
| 840 | + }; | |
| 841 | + | |
| 842 | + const groups: [string, CatalogBlock[]][] = []; | |
| 843 | + for (const b of cat ?? []) { | |
| 844 | + const g = groups.find(([s]) => s === b.section); | |
| 845 | + if (g) g[1].push(b); else groups.push([b.section, [b]]); | |
| 846 | + } | |
| 847 | + const selKeys = new Set(sel.map((s) => s.key)); | |
| 848 | + const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" }; | |
| 849 | + | |
| 850 | + return ( | |
| 851 | + <div role="dialog" aria-modal="true" aria-label="Rapport personnalisé" | |
| 852 | + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} | |
| 853 | + style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}> | |
| 854 | + <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}> | |
| 855 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}> | |
| 856 | + <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}> | |
| 857 | + Rapport personnalisé <span className="klabel">· période : {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span> | |
| 858 | + </b> | |
| 859 | + <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button> | |
| 860 | + </div> | |
| 861 | + <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}> | |
| 862 | + <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}> | |
| 863 | + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3> | |
| 864 | + {!cat && !err && <p className="klabel">Chargement du catalogue…</p>} | |
| 865 | + {groups.map(([secId, bs]) => ( | |
| 866 | + <div key={secId}> | |
| 867 | + <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p> | |
| 868 | + {bs.map((b) => ( | |
| 869 | + <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}> | |
| 870 | + <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span> | |
| 871 | + <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button> | |
| 872 | + </div> | |
| 873 | + ))} | |
| 874 | + </div> | |
| 875 | + ))} | |
| 876 | + </div> | |
| 877 | + <div style={{ padding: "14px 20px", minWidth: 0 }}> | |
| 878 | + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3> | |
| 879 | + <label className="klabel" htmlFor="rb-title">Titre du rapport</label> | |
| 880 | + <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }} | |
| 881 | + maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} /> | |
| 882 | + {sel.length ? sel.map((s, i) => { | |
| 883 | + const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock; | |
| 884 | + return ( | |
| 885 | + <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}> | |
| 886 | + <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button> | |
| 887 | + <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button> | |
| 888 | + <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span> | |
| 889 | + {b.renders.length > 1 ? ( | |
| 890 | + <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }} | |
| 891 | + onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}> | |
| 892 | + {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)} | |
| 893 | + </select> | |
| 894 | + ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>} | |
| 895 | + <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button> | |
| 896 | + </div> | |
| 897 | + ); | |
| 898 | + }) : ( | |
| 899 | + <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}> | |
| 900 | + Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous. | |
| 901 | + </div> | |
| 902 | + )} | |
| 903 | + <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}> | |
| 904 | + <button type="button" className="btn btn-ghost" disabled={!cat?.length} | |
| 905 | + onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Tout ajouter</button> | |
| 906 | + <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button> | |
| 907 | + </p> | |
| 908 | + {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>} | |
| 909 | + </div> | |
| 910 | + </div> | |
| 911 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}> | |
| 912 | + <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}> | |
| 913 | + <select className="input" aria-label="Modèles sauvegardés" style={{ maxWidth: 210 }} value="" | |
| 914 | + onChange={(e) => { | |
| 915 | + const t = tpls[Number(e.target.value)]; | |
| 916 | + if (!t) return; | |
| 917 | + setTitle(t.title || t.name); | |
| 918 | + setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s }))); | |
| 919 | + }}> | |
| 920 | + <option value="">Modèles ({tpls.length})…</option> | |
| 921 | + {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)} | |
| 922 | + </select> | |
| 923 | + <button type="button" className="btn btn-ghost" disabled={!sel.length} | |
| 924 | + onClick={() => { | |
| 925 | + const name = window.prompt("Nom du modèle :", title || "Mon rapport"); | |
| 926 | + if (!name) return; | |
| 927 | + const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }]; | |
| 928 | + setTpls(next); saveTemplates(next); | |
| 929 | + }}>💾 Sauvegarder</button> | |
| 930 | + <button type="button" className="btn btn-ghost" disabled={!tpls.length} | |
| 931 | + onClick={() => { | |
| 932 | + const name = window.prompt(`Nom du modèle à supprimer :\n${tpls.map((t) => `· ${t.name}`).join("\n")}`); | |
| 933 | + if (!name) return; | |
| 934 | + const next = tpls.filter((t) => t.name !== name); | |
| 935 | + setTpls(next); saveTemplates(next); | |
| 936 | + }}>🗑 Supprimer</button> | |
| 937 | + </span> | |
| 938 | + <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}> | |
| 939 | + {busy ? "Génération…" : "⬇ Générer le PDF"} | |
| 940 | + </button> | |
| 941 | + </div> | |
| 942 | + </div> | |
| 943 | + </div> | |
| 944 | + ); | |
| 945 | +} | |
| 946 | + | |
| 735 | 947 | /* ---------- États ---------- */ |
| 736 | 948 | export function EmptyBlock({ title }: { title: string }) { |
| 737 | 949 | return ( |
modified
src/ka/stats/kapdf.py
+398 −8
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2 | |
| 2 | +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3 | |
| 3 | 3 | # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit |
| 4 | −# les rapports estampillés Groupe-KA. 5 modes : | |
| 4 | +# les rapports estampillés Groupe-KA. 5 modes fixes : | |
| 5 | 5 | # complet — toutes les sections (KPI, jauges, séries + stats, multi- |
| 6 | 6 | # séries, empilées, distributions, répartitions, géo, |
| 7 | 7 | # heatmap horaire, tableaux, records) |
@@ -9,12 +9,19 @@ | ||
| 9 | 9 | # tendances — KPI + toutes les séries temporelles + stats de séries |
| 10 | 10 | # repartitions — breakdowns, distributions, géo, activité horaire |
| 11 | 11 | # donnees — tous les tableaux en version longue (400 lignes max) |
| 12 | +# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par | |
| 13 | +# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/ | |
| 14 | +# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport | |
| 15 | +# suit une spec {"title": str, "blocks": [{"key": "series:ajouts", | |
| 16 | +# "render": "bar"}, …]} et respecte l'ordre demandé. | |
| 12 | 17 | # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. |
| 13 | 18 | # Usage : |
| 14 | −# from kapdf import GroupeKAReport, REPORT_MODES, filename | |
| 19 | +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename | |
| 15 | 20 | # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", |
| 16 | 21 | # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, |
| 17 | 22 | # mode="complet").build() |
| 23 | +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise", | |
| 24 | +# spec={"title": "Mon rapport", "blocks": [...]}).build() | |
| 18 | 25 | # Dépendance : pip install fpdf2 (aucune autre) |
| 19 | 26 | from __future__ import annotations |
| 20 | 27 | |
@@ -40,6 +47,100 @@ REPORT_MODES = { | ||
| 40 | 47 | "repartitions": "Répartitions & géographie", |
| 41 | 48 | "donnees": "Données détaillées", |
| 42 | 49 | } |
| 50 | +# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes) | |
| 51 | +CUSTOM_MODE = "personnalise" | |
| 52 | +CUSTOM_LABEL = "Rapport personnalisé" | |
| 53 | + | |
| 54 | +# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ; | |
| 55 | +# « table » est toujours offert : toute donnée a un équivalent tableau) | |
| 56 | +RENDER_LABELS = { | |
| 57 | + "line": "Courbe", "area": "Aire", "bar": "Barres verticales", | |
| 58 | + "bars": "Barres horizontales", "donut": "Anneau", | |
| 59 | + "lines": "Multi-courbes", "stacked": "Barres empilées", | |
| 60 | + "histogram": "Histogramme", "heatmap": "Heatmap", | |
| 61 | + "cards": "Cartes", "gauges": "Jauges", "table": "Tableau", | |
| 62 | +} | |
| 63 | +SECTION_LABELS = { | |
| 64 | + "kpis": "Indicateurs", "gauges": "Taux & couvertures", | |
| 65 | + "series": "Évolution", "multiseries": "Comparaisons", | |
| 66 | + "stacked": "Compositions", "breakdowns": "Répartitions", | |
| 67 | + "distributions": "Distributions", "geo": "Géographie", | |
| 68 | + "heatmap": "Calendrier", "hourly": "Activité horaire", | |
| 69 | + "tables": "Tableaux", "records": "Records", | |
| 70 | +} | |
| 71 | + | |
| 72 | + | |
| 73 | +def catalog(dash: dict) -> list[dict]: | |
| 74 | + """v3 — blocs composables d'un dashboard : ce que le constructeur de | |
| 75 | + rapports personnalisés peut inclure, avec les rendus compatibles. | |
| 76 | + key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard.""" | |
| 77 | + out: list[dict] = [] | |
| 78 | + | |
| 79 | + def add(key, title, renders, default=None, count=None): | |
| 80 | + b = {"key": key, "section": key.split(":")[0], "title": title, | |
| 81 | + "renders": renders, "default_render": default or renders[0]} | |
| 82 | + if count is not None: | |
| 83 | + b["count"] = count | |
| 84 | + out.append(b) | |
| 85 | + | |
| 86 | + if dash.get("kpis"): | |
| 87 | + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], | |
| 88 | + count=len(dash["kpis"])) | |
| 89 | + gs = [g for g in (dash.get("gauges") or []) | |
| 90 | + if isinstance(g.get("value"), (int, float)) and g.get("max")] | |
| 91 | + if gs: | |
| 92 | + add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"], | |
| 93 | + count=len(gs)) | |
| 94 | + for s in dash.get("series") or []: | |
| 95 | + if len(s.get("points") or []) < 2: | |
| 96 | + continue | |
| 97 | + kind = s.get("kind") or "line" | |
| 98 | + default = kind if kind in ("line", "area", "bar") else "line" | |
| 99 | + add(f"series:{s.get('id')}", s.get("title", ""), | |
| 100 | + ["line", "area", "bar", "table"], default, | |
| 101 | + len(s.get("points") or [])) | |
| 102 | + for ms in dash.get("multiseries") or []: | |
| 103 | + if not (ms.get("series") or []): | |
| 104 | + continue | |
| 105 | + add(f"multiseries:{ms.get('id')}", ms.get("title", ""), | |
| 106 | + ["lines", "table"], count=len(ms["series"])) | |
| 107 | + for st in dash.get("stacked") or []: | |
| 108 | + if not (st.get("points") or []): | |
| 109 | + continue | |
| 110 | + add(f"stacked:{st.get('id')}", st.get("title", ""), | |
| 111 | + ["stacked", "table"], count=len(st.get("keys") or [])) | |
| 112 | + for b in dash.get("breakdowns") or []: | |
| 113 | + if not (b.get("items") or []): | |
| 114 | + continue | |
| 115 | + default = "donut" if b.get("kind") == "donut" else "bars" | |
| 116 | + add(f"breakdowns:{b.get('id')}", b.get("title", ""), | |
| 117 | + ["donut", "bars", "table"], default, len(b["items"])) | |
| 118 | + for d in dash.get("distributions") or []: | |
| 119 | + if not (d.get("bins") or []): | |
| 120 | + continue | |
| 121 | + add(f"distributions:{d.get('id')}", d.get("title", ""), | |
| 122 | + ["histogram", "table"], count=len(d["bins"])) | |
| 123 | + geo = dash.get("geo") or {} | |
| 124 | + if geo.get("items"): | |
| 125 | + add("geo", geo.get("title", "Répartition géographique"), | |
| 126 | + ["bars", "table"], count=len(geo["items"])) | |
| 127 | + hm = dash.get("heatmap") or {} | |
| 128 | + if hm.get("cells"): | |
| 129 | + add("heatmap", hm.get("title", "Calendrier d'activité"), | |
| 130 | + ["heatmap", "table"]) | |
| 131 | + hr = dash.get("hourly") or {} | |
| 132 | + if hr.get("cells"): | |
| 133 | + add("hourly", hr.get("title", "Activité par jour et heure"), | |
| 134 | + ["heatmap", "table"]) | |
| 135 | + for t in dash.get("tables") or []: | |
| 136 | + if not (t.get("rows") or []): | |
| 137 | + continue | |
| 138 | + add(f"tables:{t.get('id')}", t.get("title", ""), ["table"], | |
| 139 | + count=len(t["rows"])) | |
| 140 | + if dash.get("records"): | |
| 141 | + add("records", "Records & faits marquants", ["cards", "table"], | |
| 142 | + count=len(dash["records"])) | |
| 143 | + return out | |
| 43 | 144 | |
| 44 | 145 | EMAILS = [ |
| 45 | 146 | ("contact@groupe-ka.com", "Projets, partenariats & données"), |
@@ -127,16 +228,25 @@ class _PDF(FPDF): | ||
| 127 | 228 | |
| 128 | 229 | |
| 129 | 230 | class GroupeKAReport: |
| 130 | − def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): | |
| 231 | + def __init__(self, site: dict, dashboard: dict, mode: str = "complet", | |
| 232 | + spec: dict | None = None): | |
| 131 | 233 | self.site = site |
| 132 | 234 | self.d = dashboard |
| 133 | − self.mode = mode if mode in REPORT_MODES else "complet" | |
| 235 | + self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet" | |
| 236 | + self.spec = spec or {} | |
| 134 | 237 | self.accent = _hex(site.get("accent", "#d9f26b")) |
| 135 | 238 | period = dashboard.get("period", {}) or {} |
| 136 | 239 | self.period_label = period.get("label") or "toute la période" |
| 137 | 240 | self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) |
| 138 | 241 | self.toc: list[tuple[str, int]] = [] |
| 139 | 242 | |
| 243 | + @property | |
| 244 | + def mode_label(self) -> str: | |
| 245 | + if self.mode == CUSTOM_MODE: | |
| 246 | + t = str(self.spec.get("title") or "").strip() | |
| 247 | + return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL | |
| 248 | + return REPORT_MODES[self.mode] | |
| 249 | + | |
| 140 | 250 | # ---------- primitives ---------- |
| 141 | 251 | def _card(self, x, y, w, h, fill=WHITE): |
| 142 | 252 | p = self.pdf |
@@ -213,7 +323,7 @@ class GroupeKAReport: | ||
| 213 | 323 | p.set_xy(24, 100) |
| 214 | 324 | p.set_font("helvetica", "", 13) |
| 215 | 325 | p.set_text_color(*INK2) |
| 216 | − p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}") | |
| 326 | + p.multi_cell(150, 7, f"{self.mode_label} — {wm}") | |
| 217 | 327 | now = datetime.now(ZoneInfo("America/Toronto")) |
| 218 | 328 | per = self.d.get("period", {}) or {} |
| 219 | 329 | p.set_xy(24, 125) |
@@ -222,7 +332,7 @@ class GroupeKAReport: | ||
| 222 | 332 | ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), |
| 223 | 333 | ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), |
| 224 | 334 | ("Plateforme", "https://" + self.site.get("domain", "")), |
| 225 | − ("Type de rapport", REPORT_MODES[self.mode]), | |
| 335 | + ("Type de rapport", self.mode_label), | |
| 226 | 336 | ] |
| 227 | 337 | y = 128 |
| 228 | 338 | for k, v in rows: |
@@ -685,6 +795,271 @@ class GroupeKAReport: | ||
| 685 | 795 | p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") |
| 686 | 796 | p.set_y(y0 + ly + 7 * chh + 5) |
| 687 | 797 | |
| 798 | + def _calheat(self, hm): | |
| 799 | + """v3 — calendrier de chaleur 26 semaines (équivalent PDF du | |
| 800 | + CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours.""" | |
| 801 | + from datetime import date as _date, timedelta as _td | |
| 802 | + cells = hm.get("cells") or [] | |
| 803 | + vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")} | |
| 804 | + if not vals: | |
| 805 | + return | |
| 806 | + p = self.pdf | |
| 807 | + if p.get_y() > 215: | |
| 808 | + p.add_page() | |
| 809 | + self._chart_title(hm.get("title", "Calendrier d'activité")) | |
| 810 | + try: | |
| 811 | + end = _date.fromisoformat(max(vals)) | |
| 812 | + except ValueError: | |
| 813 | + return | |
| 814 | + weeks = 26 | |
| 815 | + start = end - _td(days=weeks * 7 - 1) | |
| 816 | + start -= _td(days=start.weekday()) # lundi | |
| 817 | + vmax = max(vals.values()) or 1 | |
| 818 | + x0, y0 = p.l_margin, p.get_y() | |
| 819 | + cw, lx, ly = 6.3, 10, 4 | |
| 820 | + dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"] | |
| 821 | + p.set_font("helvetica", "", 5.8) | |
| 822 | + p.set_text_color(*INK3) | |
| 823 | + for d in range(7): | |
| 824 | + if dows[d]: | |
| 825 | + p.set_xy(x0, y0 + ly + d * cw + 1.2) | |
| 826 | + p.cell(lx - 1, 3, dows[d], align="R") | |
| 827 | + for w in range(weeks): | |
| 828 | + monday = start + _td(days=7 * w) | |
| 829 | + if monday.day <= 7: # étiquette de mois à la 1re semaine du mois | |
| 830 | + p.set_xy(x0 + lx + w * cw, y0) | |
| 831 | + p.cell(cw * 4, 3, monday.strftime("%m")) | |
| 832 | + for d in range(7): | |
| 833 | + day = monday + _td(days=d) | |
| 834 | + v = vals.get(day.isoformat(), 0) | |
| 835 | + f = 0.15 + 0.85 * (v / vmax) if v else 0.0 | |
| 836 | + col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) | |
| 837 | + for j in range(3)) if v else (235, 233, 228)) | |
| 838 | + p.set_fill_color(*col) | |
| 839 | + p.set_draw_color(215, 213, 207) | |
| 840 | + p.set_line_width(0.1) | |
| 841 | + p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5, | |
| 842 | + style="DF") | |
| 843 | + p.set_y(y0 + ly + 7 * cw + 5) | |
| 844 | + | |
| 845 | + # ---------- v3 : conversions bloc → tableau ---------- | |
| 846 | + @staticmethod | |
| 847 | + def _serie_as_table(s): | |
| 848 | + unit = s.get("unit") or "Valeur" | |
| 849 | + cols = ["Date", unit.capitalize()] | |
| 850 | + cmp_ = s.get("compare") or [] | |
| 851 | + if cmp_: | |
| 852 | + cols.append("Période comparée") | |
| 853 | + rows = [] | |
| 854 | + for i, pt in enumerate(s.get("points") or []): | |
| 855 | + row = [str(pt.get("t", "")), pt.get("v", "")] | |
| 856 | + if cmp_: | |
| 857 | + row.append(cmp_[i]["v"] if i < len(cmp_) else "") | |
| 858 | + rows.append(row) | |
| 859 | + return {"id": s.get("id"), "title": s.get("title", ""), | |
| 860 | + "columns": cols, "rows": rows} | |
| 861 | + | |
| 862 | + @staticmethod | |
| 863 | + def _multi_as_table(ms): | |
| 864 | + labels = [s.get("label", "") for s in (ms.get("series") or [])][:4] | |
| 865 | + by_t: dict[str, dict] = {} | |
| 866 | + for s in (ms.get("series") or [])[:4]: | |
| 867 | + for pt in s.get("points") or []: | |
| 868 | + by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v") | |
| 869 | + rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels] | |
| 870 | + for t in sorted(by_t)] | |
| 871 | + return {"id": ms.get("id"), "title": ms.get("title", ""), | |
| 872 | + "columns": ["Date"] + labels, "rows": rows} | |
| 873 | + | |
| 874 | + @staticmethod | |
| 875 | + def _stacked_as_table(st): | |
| 876 | + keys = (st.get("keys") or [])[:6] | |
| 877 | + rows = [] | |
| 878 | + for pt in st.get("points") or []: | |
| 879 | + vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0 | |
| 880 | + for j in range(len(keys))] | |
| 881 | + rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)]) | |
| 882 | + return {"id": st.get("id"), "title": st.get("title", ""), | |
| 883 | + "columns": ["Date"] + list(keys) + ["Total"], "rows": rows} | |
| 884 | + | |
| 885 | + @staticmethod | |
| 886 | + def _items_as_table(id_, title, items, label_col="Libellé"): | |
| 887 | + items = items or [] | |
| 888 | + with_delta = any(it.get("delta_pct") is not None for it in items) | |
| 889 | + cols = [label_col, "Valeur"] + (["delta %"] if with_delta else []) | |
| 890 | + rows = [] | |
| 891 | + for it in items: | |
| 892 | + row = [str(it.get("label", "")), it.get("value", "")] | |
| 893 | + if with_delta: | |
| 894 | + d = it.get("delta_pct") | |
| 895 | + row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %") | |
| 896 | + rows.append(row) | |
| 897 | + return {"id": id_, "title": title, "columns": cols, "rows": rows} | |
| 898 | + | |
| 899 | + def _kpis_as_table(self): | |
| 900 | + rows = [] | |
| 901 | + for k in self.d.get("kpis") or []: | |
| 902 | + v = k.get("value") | |
| 903 | + val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \ | |
| 904 | + ((" " + k["unit"]) if k.get("unit") else "") | |
| 905 | + d = k.get("delta_pct") | |
| 906 | + rows.append([str(k.get("label", "")), val, | |
| 907 | + "" if d is None else f"{'+' if d >= 0 else ''}{d} %"]) | |
| 908 | + return {"id": "kpis", "title": "Indicateurs clés", | |
| 909 | + "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows} | |
| 910 | + | |
| 911 | + def _gauges_as_table(self): | |
| 912 | + rows = [[str(g.get("label", "")), | |
| 913 | + f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", | |
| 914 | + _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"] | |
| 915 | + for g in self.d.get("gauges") or [] | |
| 916 | + if isinstance(g.get("value"), (int, float)) and g.get("max")] | |
| 917 | + return {"id": "gauges", "title": "Taux & couvertures", | |
| 918 | + "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows} | |
| 919 | + | |
| 920 | + def _records_as_table(self): | |
| 921 | + rows = [[str(r.get("label", "")), str(r.get("value", "")), | |
| 922 | + str(r.get("date", "") or "")] | |
| 923 | + for r in self.d.get("records") or []] | |
| 924 | + return {"id": "records", "title": "Records & faits marquants", | |
| 925 | + "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows} | |
| 926 | + | |
| 927 | + @staticmethod | |
| 928 | + def _heatmap_as_table(hm, title): | |
| 929 | + cells = sorted((hm.get("cells") or []), | |
| 930 | + key=lambda c: -(c.get("value") or 0))[:40] | |
| 931 | + return {"id": "heatmap", "title": title + " — jours les plus chargés", | |
| 932 | + "columns": ["Date", "Valeur"], | |
| 933 | + "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]} | |
| 934 | + | |
| 935 | + @staticmethod | |
| 936 | + def _hourly_as_table(hr, title): | |
| 937 | + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", | |
| 938 | + "Dimanche"] | |
| 939 | + cells = sorted((hr.get("cells") or []), | |
| 940 | + key=lambda c: -(c.get("value") or 0))[:40] | |
| 941 | + return {"id": "hourly", "title": title + " — créneaux les plus actifs", | |
| 942 | + "columns": ["Jour", "Heure", "Valeur"], | |
| 943 | + "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?", | |
| 944 | + f"{c.get('hour', '?')} h", c.get("value") or 0] | |
| 945 | + for c in cells]} | |
| 946 | + | |
| 947 | + # ---------- v3 : rendu d'un bloc du rapport personnalisé ---------- | |
| 948 | + def _find(self, coll: str, id_: str): | |
| 949 | + for it in self.d.get(coll) or []: | |
| 950 | + if str(it.get("id")) == id_: | |
| 951 | + return it | |
| 952 | + return None | |
| 953 | + | |
| 954 | + def _toc_mark(self, title: str): | |
| 955 | + """Blocs graphiques du mode personnalisé : entrée de sommaire sans | |
| 956 | + _section_title (le graphique porte déjà son titre).""" | |
| 957 | + if self.pdf.get_y() > 235: | |
| 958 | + self.pdf.add_page() | |
| 959 | + self.toc.append((title, self.pdf.page_no())) | |
| 960 | + | |
| 961 | + def _render_block(self, key: str, render: str): | |
| 962 | + section, _, id_ = key.partition(":") | |
| 963 | + if section == "kpis": | |
| 964 | + self._table(self._kpis_as_table()) if render == "table" else self._kpis() | |
| 965 | + elif section == "gauges": | |
| 966 | + self._table(self._gauges_as_table()) if render == "table" else self._gauges() | |
| 967 | + elif section == "records": | |
| 968 | + self._table(self._records_as_table()) if render == "table" else self._records() | |
| 969 | + elif section == "series": | |
| 970 | + s = self._find("series", id_) | |
| 971 | + if not s: | |
| 972 | + return | |
| 973 | + if render == "table": | |
| 974 | + self._table(self._serie_as_table(s), max_rows=400) | |
| 975 | + else: | |
| 976 | + s2 = dict(s) | |
| 977 | + if render in ("line", "area", "bar"): | |
| 978 | + s2["kind"] = render | |
| 979 | + self._toc_mark(s2.get("title", "")) | |
| 980 | + if s2.get("kind") == "bar": | |
| 981 | + self._vbars(s2) | |
| 982 | + else: | |
| 983 | + self._line_chart(s2, with_stats=True) | |
| 984 | + elif section == "multiseries": | |
| 985 | + ms = self._find("multiseries", id_) | |
| 986 | + if not ms: | |
| 987 | + return | |
| 988 | + if render == "table": | |
| 989 | + self._table(self._multi_as_table(ms), max_rows=400) | |
| 990 | + else: | |
| 991 | + self._toc_mark(ms.get("title", "")) | |
| 992 | + self._multiline(ms) | |
| 993 | + elif section == "stacked": | |
| 994 | + st = self._find("stacked", id_) | |
| 995 | + if not st: | |
| 996 | + return | |
| 997 | + if render == "table": | |
| 998 | + self._table(self._stacked_as_table(st), max_rows=400) | |
| 999 | + else: | |
| 1000 | + self._toc_mark(st.get("title", "")) | |
| 1001 | + self._stacked(st) | |
| 1002 | + elif section == "breakdowns": | |
| 1003 | + b = self._find("breakdowns", id_) | |
| 1004 | + if not b: | |
| 1005 | + return | |
| 1006 | + if render == "table": | |
| 1007 | + self._table(self._items_as_table(id_, b.get("title", ""), | |
| 1008 | + b.get("items")), max_rows=400) | |
| 1009 | + else: | |
| 1010 | + self._toc_mark(b.get("title", "")) | |
| 1011 | + if render == "donut": | |
| 1012 | + self._donut(b) | |
| 1013 | + else: | |
| 1014 | + self._bars(b.get("title", ""), b.get("items")) | |
| 1015 | + elif section == "distributions": | |
| 1016 | + d = self._find("distributions", id_) | |
| 1017 | + if not d: | |
| 1018 | + return | |
| 1019 | + if render == "table": | |
| 1020 | + bins = [{"label": bn.get("label"), "value": bn.get("value")} | |
| 1021 | + for bn in d.get("bins") or []] | |
| 1022 | + self._table(self._items_as_table(id_, d.get("title", ""), bins, | |
| 1023 | + label_col="Tranche")) | |
| 1024 | + else: | |
| 1025 | + self._toc_mark(d.get("title", "")) | |
| 1026 | + self._vbars(d) | |
| 1027 | + elif section == "geo": | |
| 1028 | + geo = self.d.get("geo") or {} | |
| 1029 | + if not geo.get("items"): | |
| 1030 | + return | |
| 1031 | + title = geo.get("title", "Répartition géographique") | |
| 1032 | + if render == "table": | |
| 1033 | + self._table(self._items_as_table("geo", title, geo["items"], | |
| 1034 | + label_col="Zone"), max_rows=400) | |
| 1035 | + else: | |
| 1036 | + self._toc_mark(title) | |
| 1037 | + self._bars(title, geo["items"]) | |
| 1038 | + elif section == "heatmap": | |
| 1039 | + hm = self.d.get("heatmap") or {} | |
| 1040 | + if not hm.get("cells"): | |
| 1041 | + return | |
| 1042 | + title = hm.get("title", "Calendrier d'activité") | |
| 1043 | + if render == "table": | |
| 1044 | + self._table(self._heatmap_as_table(hm, title)) | |
| 1045 | + else: | |
| 1046 | + self._toc_mark(title) | |
| 1047 | + self._calheat(hm) | |
| 1048 | + elif section == "hourly": | |
| 1049 | + hr = self.d.get("hourly") or {} | |
| 1050 | + if not hr.get("cells"): | |
| 1051 | + return | |
| 1052 | + title = hr.get("title", "Activité par jour et heure") | |
| 1053 | + if render == "table": | |
| 1054 | + self._table(self._hourly_as_table(hr, title)) | |
| 1055 | + else: | |
| 1056 | + self._toc_mark(title) | |
| 1057 | + self._hourly() | |
| 1058 | + elif section == "tables": | |
| 1059 | + t = self._find("tables", id_) | |
| 1060 | + if t: | |
| 1061 | + self._table(t, max_rows=400) | |
| 1062 | + | |
| 688 | 1063 | def _table(self, t, max_rows=200): |
| 689 | 1064 | p = self.pdf |
| 690 | 1065 | cols = t.get("columns") or [] |
@@ -809,7 +1184,7 @@ class GroupeKAReport: | ||
| 809 | 1184 | p = self.pdf |
| 810 | 1185 | p.alias_nb_pages() |
| 811 | 1186 | self._cover() |
| 812 | − with_toc = self.mode in ("complet", "donnees") | |
| 1187 | + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE) | |
| 813 | 1188 | toc_page_no = None |
| 814 | 1189 | if self.mode == "synthese": |
| 815 | 1190 | p.add_page() |
@@ -835,6 +1210,21 @@ class GroupeKAReport: | ||
| 835 | 1210 | for t in self.d.get("tables") or []: |
| 836 | 1211 | self._table(t, max_rows=400) |
| 837 | 1212 | self._final_page() |
| 1213 | + elif self.mode == CUSTOM_MODE: | |
| 1214 | + p.add_page() | |
| 1215 | + toc_page_no = p.page_no() | |
| 1216 | + p.add_page() | |
| 1217 | + known = {b["key"]: b for b in catalog(self.d)} | |
| 1218 | + for blk in self.spec.get("blocks") or []: | |
| 1219 | + key = str(blk.get("key", "")) | |
| 1220 | + b = known.get(key) | |
| 1221 | + if not b: | |
| 1222 | + continue | |
| 1223 | + render = str(blk.get("render") or "") | |
| 1224 | + if render not in b["renders"]: | |
| 1225 | + render = b["default_render"] | |
| 1226 | + self._render_block(key, render) | |
| 1227 | + self._final_page() | |
| 838 | 1228 | else: # complet |
| 839 | 1229 | p.add_page() |
| 840 | 1230 | toc_page_no = p.page_no() |
added
src/lib/report-stats.ts
+898 −0
@@ -0,0 +1,898 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Rapports statistiques PDF locaux du hub — GABARIT COMMUN GROUPE KA | |
| 4 | + * (src/ka/stats/SPEC.md), moteur pdfkit (équivalent TS de kapdf.py) : | |
| 5 | + * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied | |
| 6 | + * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires, | |
| 7 | + * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges, | |
| 8 | + * calendrier quotidien ET horaire), statistiques de séries (min/max/moy/méd/σ), | |
| 9 | + * tableaux zébrés paginés, records, page de fin avec coordonnées. | |
| 10 | + * | |
| 11 | + * Les 5 modes v2 du hub restent générés par API-KA (proxy | |
| 12 | + * /api/stats/ecosystem-report) — ce module ne porte QUE la v3 : | |
| 13 | + * rapports personnalisés (SPEC §3bis) composés depuis l'EcoPayload | |
| 14 | + * consolidé (src/lib/ecostats.ts — mêmes chiffres que la page /stats, | |
| 15 | + * aucune donnée inventée). | |
| 16 | + */ | |
| 17 | +import PDFDocument from "pdfkit"; | |
| 18 | +import path from "path"; | |
| 19 | +import eco from "@/ka/ecosystem.json"; | |
| 20 | +import { | |
| 21 | + PERIODS, | |
| 22 | + isPeriod, | |
| 23 | + buildEcoPayload, | |
| 24 | + getEcosystemStats, | |
| 25 | + type EcoPayload, | |
| 26 | + type PeriodKey, | |
| 27 | +} from "@/lib/ecostats"; | |
| 28 | +import { getLiveMetrics } from "@/lib/live"; | |
| 29 | +import type { | |
| 30 | + Kpi as ChartKpi, | |
| 31 | + Serie as ChartSerie, | |
| 32 | + MultiSerie as ChartMultiSerie, | |
| 33 | + StackedSerie as ChartStacked, | |
| 34 | + BreakItem, | |
| 35 | + Gauge as ChartGauge, | |
| 36 | + HourCell, | |
| 37 | + RecordFact, | |
| 38 | +} from "@/ka/stats/kacharts"; | |
| 39 | + | |
| 40 | +const INK = "#141814"; | |
| 41 | +const INK2 = "#4d5551"; | |
| 42 | +const INK3 = "#8b928c"; | |
| 43 | +const PAPER = "#f5f3ee"; | |
| 44 | +const SURFACE2 = "#faf9f5"; | |
| 45 | +const GREEN = "#123f2e"; // accentDeep Groupe-KA (ecosystem.json) | |
| 46 | +const GREEN_DEEP = "#0b2a1e"; | |
| 47 | +const LIME = "#d9f26b"; // accent Groupe-KA (ecosystem.json) | |
| 48 | +const WHITE = "#ffffff"; | |
| 49 | + | |
| 50 | +/* A4 portrait (gabarit Groupe-KA) */ | |
| 51 | +const W = 595.28; | |
| 52 | +const H = 841.89; | |
| 53 | +const M = 44; | |
| 54 | +const CW = W - 2 * M; | |
| 55 | +const TOP = 56; // début du contenu sous l'en-tête | |
| 56 | +const BOT = H - 58; // fin du contenu au-dessus du pied | |
| 57 | + | |
| 58 | +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); | |
| 59 | +const num = (v: number) => v.toLocaleString("fr-CA"); | |
| 60 | +/** Nombre court pour axes/valeurs de graphiques. */ | |
| 61 | +const short = (v: number) => { | |
| 62 | + if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`; | |
| 63 | + if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`; | |
| 64 | + if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`; | |
| 65 | + if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`; | |
| 66 | + return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 }); | |
| 67 | +}; | |
| 68 | +/** Valeur de comptage : entière lisible, compacte au-delà du million. */ | |
| 69 | +const count = (v: number) => (Math.abs(v) >= 1e6 ? short(v) : num(v)); | |
| 70 | +const DOW_FR = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; | |
| 71 | + | |
| 72 | +type Doc = InstanceType<typeof PDFDocument>; | |
| 73 | + | |
| 74 | +/* Libellé de période du rapport en cours (le dessin pdfkit est synchrone : | |
| 75 | + la variable est posée au début de chaque build, aucun entrelacement). */ | |
| 76 | +let PERIOD_LABEL = ""; | |
| 77 | + | |
| 78 | +/** Wordmark Groupe-KA : « Groupe » encre + boîte encre / « KA » lime. */ | |
| 79 | +function wordmark(doc: Doc, x: number, y: number, size = 24) { | |
| 80 | + doc.font("SG-Bold").fontSize(size); | |
| 81 | + doc.fillColor(INK).text("Groupe", x, y, { lineBreak: false }); | |
| 82 | + const w = doc.widthOfString("Groupe"); | |
| 83 | + const bx = x + w + 0.22 * size; | |
| 84 | + const bw = doc.widthOfString("KA") + 0.5 * size; | |
| 85 | + doc.roundedRect(bx, y - 0.14 * size, bw, 1.28 * size, 0.18 * size).fill(INK); | |
| 86 | + doc.fillColor(LIME).text("KA", bx + 0.25 * size, y, { lineBreak: false }); | |
| 87 | +} | |
| 88 | + | |
| 89 | +function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) { | |
| 90 | + doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN); | |
| 91 | + doc.font("JB-Bold").fontSize(size).fillColor(GREEN) | |
| 92 | + .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false, width: CW - 2.5 * size - 6, height: size * 1.6, ellipsis: true }); | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */ | |
| 96 | +function chrome(doc: Doc) { | |
| 97 | + const year = new Date().getFullYear(); | |
| 98 | + doc.rect(0, 0, W, H).fill(PAPER); | |
| 99 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK) | |
| 100 | + .text("Groupe KA · Portail de l'écosystème", M, 22, { lineBreak: false }); | |
| 101 | + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | |
| 102 | + .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 }); | |
| 103 | + doc.rect(M, 37, CW, 1.1).fill(INK); | |
| 104 | + doc.rect(M, H - 42, CW, 0.7).fill(INK3); | |
| 105 | + doc.font("JB-Reg").fontSize(6.8).fillColor(INK3) | |
| 106 | + .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false }); | |
| 107 | +} | |
| 108 | + | |
| 109 | +function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") { | |
| 110 | + doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9"); | |
| 111 | + doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK); | |
| 112 | +} | |
| 113 | + | |
| 114 | +function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) { | |
| 115 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false }); | |
| 116 | + const tx = x + 88; | |
| 117 | + const tw = w - 88 - 118; | |
| 118 | + doc.rect(tx, y, tw, 13).fill(SURFACE2); | |
| 119 | + doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN); | |
| 120 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" }); | |
| 121 | + if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" }); | |
| 122 | +} | |
| 123 | + | |
| 124 | +/* ============================ curseur de page ============================ */ | |
| 125 | +class Cursor { | |
| 126 | + y = TOP; | |
| 127 | + sections: { label: string; page: number }[] = []; | |
| 128 | + constructor(public doc: Doc) {} | |
| 129 | + page() { | |
| 130 | + // bufferedPageRange().count = pages déjà créées (1-based pour l'humain) | |
| 131 | + return this.doc.bufferedPageRange().count; | |
| 132 | + } | |
| 133 | + ensure(h: number) { | |
| 134 | + if (this.y + h > BOT) { | |
| 135 | + this.doc.addPage({ size: "A4", margin: 0 }); | |
| 136 | + chrome(this.doc); | |
| 137 | + this.y = TOP; | |
| 138 | + } | |
| 139 | + } | |
| 140 | + section(label: string, remember = true) { | |
| 141 | + this.ensure(30); | |
| 142 | + if (remember) this.sections.push({ label, page: this.page() }); | |
| 143 | + kicker(this.doc, label, M, this.y); | |
| 144 | + this.y += 20; | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +/* ============================ primitives graphiques ============================ */ | |
| 149 | + | |
| 150 | +/** Cadre de graphique : carte + titre, renvoie la zone de tracé. */ | |
| 151 | +function chartFrame(c: Cursor, title: string, h: number) { | |
| 152 | + c.ensure(h + 34); | |
| 153 | + const { doc } = c; | |
| 154 | + card(doc, M, c.y, CW, h + 26); | |
| 155 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK) | |
| 156 | + .text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false }); | |
| 157 | + const area = { x: M + 52, y: c.y + 30, w: CW - 52 - 22, h: h - 14 }; | |
| 158 | + c.y += h + 26 + 12; | |
| 159 | + return area; | |
| 160 | +} | |
| 161 | + | |
| 162 | +function axes(doc: Doc, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number, labels: string[]) { | |
| 163 | + for (let g = 0; g <= 4; g++) { | |
| 164 | + const y = a.y + (a.h * g) / 4; | |
| 165 | + const v = vmax - ((vmax - vmin) * g) / 4; | |
| 166 | + doc.rect(a.x, y, a.w, 0.5).fill("#e3e1d9"); | |
| 167 | + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) | |
| 168 | + .text(short(v), a.x - 40, y - 2.5, { width: 36, align: "right", lineBreak: false }); | |
| 169 | + } | |
| 170 | + const idx = [0, Math.floor(labels.length / 2), labels.length - 1].filter((v, i, arr) => arr.indexOf(v) === i); | |
| 171 | + idx.forEach((i) => { | |
| 172 | + const x = a.x + (labels.length > 1 ? (a.w * i) / (labels.length - 1) : 0); | |
| 173 | + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) | |
| 174 | + .text(labels[i] ?? "", x - 24, a.y + a.h + 4, { width: 48, align: "center", lineBreak: false }); | |
| 175 | + }); | |
| 176 | +} | |
| 177 | + | |
| 178 | +function linePdf(c: Cursor, s: ChartSerie, h = 120) { | |
| 179 | + const pts = s.points ?? []; | |
| 180 | + if (pts.length < 2) return; | |
| 181 | + const a = chartFrame(c, s.title, h); | |
| 182 | + const { doc } = c; | |
| 183 | + const vmax = Math.max(...pts.map((p) => p.v), 1); | |
| 184 | + const vmin = Math.min(0, ...pts.map((p) => p.v)); | |
| 185 | + axes(doc, a, vmin, vmax, pts.map((p) => p.t)); | |
| 186 | + const X = (i: number) => a.x + (a.w * i) / (pts.length - 1); | |
| 187 | + const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / (vmax - vmin || 1)); | |
| 188 | + if (s.kind === "bar") { | |
| 189 | + const bw = Math.max(1.2, a.w / pts.length - 1.2); | |
| 190 | + pts.forEach((p, i) => { | |
| 191 | + const bh = a.h * ((p.v - Math.max(0, vmin)) / (vmax - vmin || 1)); | |
| 192 | + doc.rect(a.x + (a.w * i) / pts.length, a.y + a.h - bh, bw, Math.max(bh, 0.6)).fill(GREEN); | |
| 193 | + }); | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + if (s.kind === "area") { | |
| 197 | + doc.moveTo(X(0), Y(pts[0].v)); | |
| 198 | + pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); | |
| 199 | + doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(a.x, a.y + a.h).closePath(); | |
| 200 | + doc.fillOpacity(0.16).fill(GREEN).fillOpacity(1); | |
| 201 | + } | |
| 202 | + doc.moveTo(X(0), Y(pts[0].v)); | |
| 203 | + pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); | |
| 204 | + doc.lineWidth(1.6).strokeColor(GREEN).stroke(); | |
| 205 | + // point final + valeur | |
| 206 | + const lastX = X(pts.length - 1), lastY = Y(pts[pts.length - 1].v); | |
| 207 | + doc.circle(lastX, lastY, 2).fill(INK); | |
| 208 | + doc.font("JB-Bold").fontSize(6).fillColor(INK) | |
| 209 | + .text(short(pts[pts.length - 1].v), lastX - 60, lastY - 10, { width: 58, align: "right", lineBreak: false }); | |
| 210 | +} | |
| 211 | + | |
| 212 | +/** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */ | |
| 213 | +function statLine(c: Cursor, s: ChartSerie) { | |
| 214 | + const vs = (s.points ?? []).map((p) => p.v); | |
| 215 | + if (vs.length < 2) return; | |
| 216 | + const sorted = [...vs].sort((x, y) => x - y); | |
| 217 | + const mean = vs.reduce((x, y) => x + y, 0) / vs.length; | |
| 218 | + const sd = Math.sqrt(vs.reduce((x, v) => x + (v - mean) ** 2, 0) / vs.length); | |
| 219 | + const items: [string, number][] = [ | |
| 220 | + ["MIN", sorted[0]], ["MAX", sorted[sorted.length - 1]], ["MOYENNE", mean], | |
| 221 | + ["MÉDIANE", sorted[Math.floor(sorted.length / 2)]], ["ÉCART-TYPE", sd], | |
| 222 | + ]; | |
| 223 | + c.ensure(16); | |
| 224 | + c.y -= 6; | |
| 225 | + const w = CW / items.length; | |
| 226 | + items.forEach(([l, v], i) => { | |
| 227 | + c.doc.font("JB-Reg").fontSize(5.5).fillColor(INK3).text(l, M + i * w, c.y, { width: w - 6, lineBreak: false }); | |
| 228 | + c.doc.font("JB-Bold").fontSize(7).fillColor(INK).text(short(v), M + i * w, c.y + 7.5, { width: w - 6, lineBreak: false }); | |
| 229 | + }); | |
| 230 | + c.y += 24; | |
| 231 | +} | |
| 232 | + | |
| 233 | +const MULTI_PDF = [ | |
| 234 | + { color: GREEN, dash: null as number[] | null, width: 1.8 }, | |
| 235 | + { color: INK, dash: null, width: 1.2 }, | |
| 236 | + { color: GREEN_DEEP, dash: [5, 2.5], width: 1.5 }, | |
| 237 | + { color: INK3, dash: [1.5, 2.5], width: 1.5 }, | |
| 238 | +]; | |
| 239 | + | |
| 240 | +function multiLinePdf(c: Cursor, ms: { title: string; unit?: string; series: { label: string; points: { t: string; v: number }[] }[] }, h = 130) { | |
| 241 | + const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4); | |
| 242 | + if (!series.length) return; | |
| 243 | + const a = chartFrame(c, ms.title + (ms.unit ? ` (${ms.unit})` : ""), h + 14); | |
| 244 | + const { doc } = c; | |
| 245 | + // légende (motif + libellé — jamais la couleur seule) | |
| 246 | + let lx = a.x; | |
| 247 | + series.forEach((s, i) => { | |
| 248 | + const st = MULTI_PDF[i]; | |
| 249 | + doc.lineWidth(2).strokeColor(st.color); | |
| 250 | + if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash(); | |
| 251 | + doc.moveTo(lx, a.y + 2).lineTo(lx + 16, a.y + 2).stroke(); | |
| 252 | + doc.undash(); | |
| 253 | + doc.font("JB-Bold").fontSize(6).fillColor(INK).text(s.label.toUpperCase(), lx + 20, a.y - 1, { lineBreak: false }); | |
| 254 | + lx += 26 + doc.widthOfString(s.label.toUpperCase()) + 14; | |
| 255 | + }); | |
| 256 | + const area = { ...a, y: a.y + 12, h: a.h - 12 }; | |
| 257 | + const all = series.flatMap((s) => s.points.map((p) => p.v)); | |
| 258 | + const vmax = Math.max(...all, 1); | |
| 259 | + const vmin = Math.min(0, ...all); | |
| 260 | + axes(doc, area, vmin, vmax, series[0].points.map((p) => p.t)); | |
| 261 | + series.forEach((s, i) => { | |
| 262 | + const st = MULTI_PDF[i]; | |
| 263 | + const X = (j: number) => area.x + (area.w * j) / (s.points.length - 1); | |
| 264 | + const Y = (v: number) => area.y + area.h * (1 - (v - vmin) / (vmax - vmin || 1)); | |
| 265 | + doc.moveTo(X(0), Y(s.points[0].v)); | |
| 266 | + s.points.forEach((p, j) => doc.lineTo(X(j), Y(p.v))); | |
| 267 | + doc.lineWidth(st.width).strokeColor(st.color); | |
| 268 | + if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash(); | |
| 269 | + doc.stroke(); | |
| 270 | + doc.undash(); | |
| 271 | + }); | |
| 272 | +} | |
| 273 | + | |
| 274 | +const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13]; | |
| 275 | + | |
| 276 | +function stackedPdf(c: Cursor, st: { title: string; unit?: string; keys: string[]; points: { t: string; values: number[] }[] }, h = 130) { | |
| 277 | + const keys = (st.keys ?? []).slice(0, 6); | |
| 278 | + const pts = st.points ?? []; | |
| 279 | + if (!keys.length || !pts.length) return; | |
| 280 | + const a = chartFrame(c, st.title, h + 14); | |
| 281 | + const { doc } = c; | |
| 282 | + let lx = a.x; | |
| 283 | + keys.forEach((k, i) => { | |
| 284 | + doc.fillOpacity(SHADES[i]).rect(lx, a.y - 1, 8, 8).fill(GREEN).fillOpacity(1); | |
| 285 | + doc.rect(lx, a.y - 1, 8, 8).lineWidth(0.5).stroke(INK); | |
| 286 | + doc.font("JB-Bold").fontSize(6).fillColor(INK).text(k.toUpperCase(), lx + 12, a.y, { lineBreak: false }); | |
| 287 | + lx += 18 + doc.widthOfString(k.toUpperCase()) + 12; | |
| 288 | + }); | |
| 289 | + const area = { ...a, y: a.y + 12, h: a.h - 12 }; | |
| 290 | + const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0)); | |
| 291 | + const vmax = Math.max(...totals, 1); | |
| 292 | + axes(doc, area, 0, vmax, pts.map((p) => p.t)); | |
| 293 | + const bw = Math.max(1, area.w / pts.length - 1); | |
| 294 | + pts.forEach((p, i) => { | |
| 295 | + const x = area.x + (area.w * i) / pts.length; | |
| 296 | + let yAcc = area.y + area.h; | |
| 297 | + keys.forEach((k, j) => { | |
| 298 | + const v = p.values[j] || 0; | |
| 299 | + const bh = area.h * (v / vmax); | |
| 300 | + yAcc -= bh; | |
| 301 | + if (bh > 0.3) { | |
| 302 | + doc.fillOpacity(SHADES[j]).rect(x, yAcc, bw, bh).fill(GREEN).fillOpacity(1); | |
| 303 | + } | |
| 304 | + }); | |
| 305 | + }); | |
| 306 | +} | |
| 307 | + | |
| 308 | +/** Anneau vectoriel (arcs tracés en petits segments) + légende. */ | |
| 309 | +function donutPdf(c: Cursor, title: string, items: { label: string; value: number }[]) { | |
| 310 | + const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8); | |
| 311 | + const total = rows.reduce((s, r) => s + r.value, 0); | |
| 312 | + if (!total) return; | |
| 313 | + const h = 150; | |
| 314 | + c.ensure(h + 40); | |
| 315 | + const { doc } = c; | |
| 316 | + card(doc, M, c.y, CW, h + 26); | |
| 317 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false }); | |
| 318 | + const cx = M + 90, cy = c.y + 30 + (h - 20) / 2, R = 52; | |
| 319 | + let angle = -Math.PI / 2; | |
| 320 | + rows.forEach((r, i) => { | |
| 321 | + const sweep = (2 * Math.PI * r.value) / total; | |
| 322 | + const steps = Math.max(2, Math.ceil(sweep / 0.06)); | |
| 323 | + doc.lineWidth(24).strokeColor(GREEN).strokeOpacity(SHADES[i % SHADES.length] * 0.9 + 0.1); | |
| 324 | + doc.moveTo(cx + R * Math.cos(angle), cy + R * Math.sin(angle)); | |
| 325 | + for (let s = 1; s <= steps; s++) { | |
| 326 | + const t = angle + (sweep * s) / steps; | |
| 327 | + doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t)); | |
| 328 | + } | |
| 329 | + doc.stroke().strokeOpacity(1); | |
| 330 | + angle += sweep; | |
| 331 | + }); | |
| 332 | + doc.circle(cx, cy, R + 12).lineWidth(0.7).strokeOpacity(0.5).stroke(INK).strokeOpacity(1); | |
| 333 | + // légende | |
| 334 | + const lx = M + 190; | |
| 335 | + let ly = c.y + 34; | |
| 336 | + rows.forEach((r, i) => { | |
| 337 | + doc.fillOpacity(SHADES[i % SHADES.length]).rect(lx, ly, 8, 8).fill(GREEN).fillOpacity(1); | |
| 338 | + doc.rect(lx, ly, 8, 8).lineWidth(0.5).stroke(INK); | |
| 339 | + doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(r.label, lx + 14, ly, { width: 200, height: 9, ellipsis: true, lineBreak: false }); | |
| 340 | + doc.font("JB-Bold").fontSize(7).fillColor(INK2) | |
| 341 | + .text(`${((100 * r.value) / total).toFixed(1).replace(".", ",")} % · ${count(r.value)}`, lx + 220, ly + 0.5, { width: CW - 220 - (lx - M) - 14, align: "right", lineBreak: false }); | |
| 342 | + ly += Math.min(17, (h - 10) / rows.length); | |
| 343 | + }); | |
| 344 | + c.y += h + 26 + 12; | |
| 345 | +} | |
| 346 | + | |
| 347 | +/** Barres horizontales (classements / répartitions / géo). */ | |
| 348 | +function hbarsPdf(c: Cursor, title: string, items: { label: string; value: number }[], fmt: (v: number) => string = count) { | |
| 349 | + const rows = (items ?? []).slice(0, 15); | |
| 350 | + if (!rows.length) return; | |
| 351 | + const boxH = rows.length * 17 + 24; | |
| 352 | + c.ensure(boxH + 30); | |
| 353 | + const { doc } = c; | |
| 354 | + c.section(title, false); | |
| 355 | + card(doc, M, c.y, CW, boxH); | |
| 356 | + const max = Math.max(...rows.map((r) => r.value), 1); | |
| 357 | + rows.forEach((r, i) => { | |
| 358 | + hbarRow(doc, M + 14, c.y + 12 + i * 17, CW - 28, r.label, r.value / max, fmt(r.value), null, i === 0); | |
| 359 | + }); | |
| 360 | + c.y += boxH + 14; | |
| 361 | +} | |
| 362 | + | |
| 363 | +/** Jauges demi-arc (accent), 4 par rangée. */ | |
| 364 | +function gaugesPdf(c: Cursor, gauges: ChartGauge[]) { | |
| 365 | + if (!gauges?.length) return; | |
| 366 | + const gw = (CW - 3 * 10) / 4; | |
| 367 | + const rowsN = Math.ceil(gauges.length / 4); | |
| 368 | + c.ensure(rowsN * 86 + 10); | |
| 369 | + const { doc } = c; | |
| 370 | + gauges.forEach((g, i) => { | |
| 371 | + const x = M + (i % 4) * (gw + 10); | |
| 372 | + const y = c.y + Math.floor(i / 4) * 86; | |
| 373 | + card(doc, x, y, gw, 78); | |
| 374 | + const cx = x + gw / 2, cy = y + 44, R = 26; | |
| 375 | + const arc = (from: number, to: number, color: string, lw: number) => { | |
| 376 | + const steps = Math.max(2, Math.ceil(((to - from) / Math.PI) * 24)); | |
| 377 | + doc.lineWidth(lw).strokeColor(color); | |
| 378 | + doc.moveTo(cx + R * Math.cos(from), cy + R * Math.sin(from)); | |
| 379 | + for (let s = 1; s <= steps; s++) { | |
| 380 | + const t = from + ((to - from) * s) / steps; | |
| 381 | + doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t)); | |
| 382 | + } | |
| 383 | + doc.stroke(); | |
| 384 | + }; | |
| 385 | + arc(Math.PI, 2 * Math.PI, "#e3e1d9", 8); | |
| 386 | + const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0)); | |
| 387 | + if (frac > 0.01) arc(Math.PI, Math.PI + Math.PI * frac, GREEN, 8); | |
| 388 | + doc.font("SG-Bold").fontSize(13).fillColor(INK) | |
| 389 | + .text(`${g.value.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}${g.unit ?? ""}`, x, cy - 12, { width: gw, align: "center", lineBreak: false }); | |
| 390 | + doc.font("JB-Bold").fontSize(5.2).fillColor(INK3) | |
| 391 | + .text(g.label.toUpperCase(), x + 8, y + 58, { width: gw - 16, align: "center", characterSpacing: 0.3, height: 16 }); | |
| 392 | + }); | |
| 393 | + c.y += rowsN * 86 + 8; | |
| 394 | +} | |
| 395 | + | |
| 396 | +/** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */ | |
| 397 | +function kpiGridPdf(c: Cursor, kpis: ChartKpi[]) { | |
| 398 | + if (!kpis?.length) return; | |
| 399 | + const kw = (CW - 3 * 10) / 4; | |
| 400 | + const rowsN = Math.ceil(kpis.length / 4); | |
| 401 | + c.ensure(rowsN * 64 + 6); | |
| 402 | + const { doc } = c; | |
| 403 | + kpis.forEach((k, i) => { | |
| 404 | + const x = M + (i % 4) * (kw + 10); | |
| 405 | + const y = c.y + Math.floor(i / 4) * 64; | |
| 406 | + card(doc, x, y, kw, 56); | |
| 407 | + const val = typeof k.value === "number" ? count(k.value) : k.value; | |
| 408 | + doc.font("SG-Bold").fontSize(12).fillColor(INK) | |
| 409 | + .text(val + (k.unit && typeof k.value === "number" ? ` ${k.unit}` : ""), x + 9, y + 9, { width: kw - 18, height: 15, ellipsis: true, lineBreak: false }); | |
| 410 | + if (k.delta_pct !== undefined && k.delta_pct !== null) { | |
| 411 | + const up = (k.direction ?? (k.delta_pct >= 0 ? "up" : "down")) === "up"; | |
| 412 | + doc.font("JB-Bold").fontSize(6.5).fillColor(up ? "#1c5c41" : "#b3423a") | |
| 413 | + .text(`${up ? "▲" : "▼"} ${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`, x + 9, y + 26, { lineBreak: false }); | |
| 414 | + } | |
| 415 | + doc.font("JB-Bold").fontSize(5).fillColor(INK3) | |
| 416 | + .text(k.label.toUpperCase(), x + 9, y + 37, { width: kw - 18, characterSpacing: 0.3, height: 15 }); | |
| 417 | + }); | |
| 418 | + c.y += rowsN * 64 + 10; | |
| 419 | +} | |
| 420 | + | |
| 421 | +/** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */ | |
| 422 | +function heatmapPdf(c: Cursor, hm: { title: string; cells: { date: string; value: number }[] }) { | |
| 423 | + if (!hm?.cells?.length) return; | |
| 424 | + const byDate = new Map(hm.cells.map((x) => [x.date, x.value])); | |
| 425 | + const dates = hm.cells.map((x) => x.date).sort(); | |
| 426 | + const end = new Date(dates[dates.length - 1] + "T12:00:00"); | |
| 427 | + const max = Math.max(...hm.cells.map((x) => x.value), 1); | |
| 428 | + const weeks = 26; | |
| 429 | + const cell = (CW - 28) / weeks; | |
| 430 | + const gridH = 7 * cell; | |
| 431 | + c.ensure(gridH + 60); | |
| 432 | + const { doc } = c; | |
| 433 | + c.section(hm.title, false); | |
| 434 | + card(doc, M, c.y, CW, gridH + 24); | |
| 435 | + const cur = new Date(end); | |
| 436 | + cur.setDate(cur.getDate() - (weeks * 7 - 1)); | |
| 437 | + for (let w = 0; w < weeks; w++) { | |
| 438 | + for (let d = 0; d < 7; d++) { | |
| 439 | + const iso = cur.toISOString().slice(0, 10); | |
| 440 | + const v = byDate.get(iso) ?? 0; | |
| 441 | + const x = M + 14 + w * cell; | |
| 442 | + const y = c.y + 12 + d * cell; | |
| 443 | + if (v > 0) { | |
| 444 | + doc.fillOpacity(0.2 + 0.8 * (v / max)).rect(x, y, cell - 1.6, cell - 1.6).fill(GREEN).fillOpacity(1); | |
| 445 | + } else { | |
| 446 | + doc.rect(x, y, cell - 1.6, cell - 1.6).fill("#eceae2"); | |
| 447 | + } | |
| 448 | + cur.setDate(cur.getDate() + 1); | |
| 449 | + } | |
| 450 | + } | |
| 451 | + c.y += gridH + 24 + 14; | |
| 452 | +} | |
| 453 | + | |
| 454 | +/** Heatmap horaire 7 jours × 24 heures (opacité ∝ valeur). */ | |
| 455 | +function hourlyPdf(c: Cursor, hm: { title: string; cells: HourCell[] }) { | |
| 456 | + if (!hm?.cells?.length) return; | |
| 457 | + const labW = 28; | |
| 458 | + const cellW = (CW - 28 - labW) / 24; | |
| 459 | + const cellH = 13; | |
| 460 | + const gridH = 7 * cellH; | |
| 461 | + c.ensure(gridH + 74); | |
| 462 | + const { doc } = c; | |
| 463 | + c.section(hm.title, false); | |
| 464 | + card(doc, M, c.y, CW, gridH + 36); | |
| 465 | + const max = Math.max(...hm.cells.map((x) => x.value), 1); | |
| 466 | + const byKey = new Map(hm.cells.map((x) => [`${x.dow}-${x.hour}`, x.value])); | |
| 467 | + const gx = M + 14 + labW; | |
| 468 | + const gy = c.y + 12; | |
| 469 | + DOW_FR.forEach((d, r) => { | |
| 470 | + doc.font("JB-Reg").fontSize(5.6).fillColor(INK3) | |
| 471 | + .text(d.toUpperCase(), M + 14, gy + r * cellH + 3.5, { lineBreak: false }); | |
| 472 | + for (let h = 0; h < 24; h++) { | |
| 473 | + const v = byKey.get(`${r}-${h}`) ?? 0; | |
| 474 | + const x = gx + h * cellW; | |
| 475 | + const y = gy + r * cellH; | |
| 476 | + if (v > 0) { | |
| 477 | + doc.fillOpacity(0.15 + 0.85 * (v / max)).rect(x, y, cellW - 1.4, cellH - 1.4).fill(GREEN).fillOpacity(1); | |
| 478 | + } else { | |
| 479 | + doc.rect(x, y, cellW - 1.4, cellH - 1.4).fill("#eceae2"); | |
| 480 | + } | |
| 481 | + } | |
| 482 | + }); | |
| 483 | + [0, 6, 12, 18, 23].forEach((h) => { | |
| 484 | + doc.font("JB-Reg").fontSize(5.6).fillColor(INK3) | |
| 485 | + .text(`${h} h`, gx + h * cellW, gy + gridH + 4, { lineBreak: false }); | |
| 486 | + }); | |
| 487 | + c.y += gridH + 36 + 14; | |
| 488 | +} | |
| 489 | + | |
| 490 | +/** Tableau zébré paginé proprement (jamais de ligne coupée). */ | |
| 491 | +function tablePdf(c: Cursor, t: { title: string; columns: string[]; rows: (string | number | null)[][] }, maxRows: number, remember = true) { | |
| 492 | + const rows = t.rows.slice(0, maxRows); | |
| 493 | + if (!rows.length) return; | |
| 494 | + const { doc } = c; | |
| 495 | + c.ensure(90); | |
| 496 | + if (remember) c.sections.push({ label: t.title, page: c.page() }); | |
| 497 | + kicker(doc, t.title, M, c.y); | |
| 498 | + c.y += 18; | |
| 499 | + // largeur des colonnes : numériques 88 pt à droite, texte se partage le reste | |
| 500 | + const isNum = t.columns.map((_, ci) => rows.every((r) => r[ci] === null || typeof r[ci] === "number")); | |
| 501 | + const numW = 88; | |
| 502 | + const rankW = 30; | |
| 503 | + const widths: number[] = t.columns.map((col, ci) => { | |
| 504 | + if (ci === 0 && (col === "Rang" || col === "#")) return rankW; | |
| 505 | + return isNum[ci] ? numW : 0; | |
| 506 | + }); | |
| 507 | + const fixed = widths.reduce((a, b) => a + b, 0); | |
| 508 | + const flexN = widths.filter((w) => w === 0).length || 1; | |
| 509 | + const flexW = (CW - fixed) / flexN; | |
| 510 | + const xs: number[] = []; | |
| 511 | + let xAcc = M; | |
| 512 | + widths.forEach((w) => { xs.push(xAcc); xAcc += w || flexW; }); | |
| 513 | + const header = () => { | |
| 514 | + doc.rect(M, c.y, CW, 15).fill(INK); | |
| 515 | + doc.font("JB-Bold").fontSize(5.6).fillColor(WHITE); | |
| 516 | + t.columns.forEach((col, ci) => { | |
| 517 | + const w = (widths[ci] || flexW) - 10; | |
| 518 | + doc.text(col.toUpperCase(), xs[ci] + 5, c.y + 5, { width: w, align: isNum[ci] && ci > 0 ? "right" : "left", lineBreak: false }); | |
| 519 | + }); | |
| 520 | + c.y += 15; | |
| 521 | + }; | |
| 522 | + header(); | |
| 523 | + const fmtCell = (v: string | number | null) => { | |
| 524 | + if (v === null || v === undefined) return "—"; | |
| 525 | + if (typeof v !== "number") return String(v); | |
| 526 | + return num(v); | |
| 527 | + }; | |
| 528 | + rows.forEach((r, ri) => { | |
| 529 | + if (c.y + 13 > BOT) { | |
| 530 | + doc.rect(M, c.y, CW, 0.9).fill(INK); | |
| 531 | + doc.addPage({ size: "A4", margin: 0 }); | |
| 532 | + chrome(doc); | |
| 533 | + c.y = TOP; | |
| 534 | + kicker(doc, `${t.title} (suite)`, M, c.y); | |
| 535 | + c.y += 18; | |
| 536 | + header(); | |
| 537 | + } | |
| 538 | + if (ri % 2 === 0) doc.rect(M, c.y, CW, 13).fill(SURFACE2); | |
| 539 | + t.columns.forEach((col, ci) => { | |
| 540 | + const w = (widths[ci] || flexW) - 10; | |
| 541 | + const right = isNum[ci] && ci > 0; | |
| 542 | + doc.font(ci === 1 && !isNum[1] ? "SG-Bold" : "JB-Reg").fontSize(6.6).fillColor(INK) | |
| 543 | + .text(fmtCell(r[ci]), xs[ci] + 5, c.y + 3.6, { width: w, align: right ? "right" : "left", height: 9, ellipsis: true, lineBreak: false }); | |
| 544 | + }); | |
| 545 | + c.y += 13; | |
| 546 | + }); | |
| 547 | + doc.rect(M, c.y, CW, 0.9).fill(INK); | |
| 548 | + c.y += 14; | |
| 549 | +} | |
| 550 | + | |
| 551 | +/** Records & faits marquants — cartes compactes 2 par rangée. */ | |
| 552 | +function recordsPdf(c: Cursor, records: RecordFact[], remember = true) { | |
| 553 | + if (!records?.length) return; | |
| 554 | + c.section("Records & faits marquants", remember); | |
| 555 | + const { doc } = c; | |
| 556 | + const rw = (CW - 10) / 2; | |
| 557 | + records.forEach((r, i) => { | |
| 558 | + if (i % 2 === 0) c.ensure(52); | |
| 559 | + const x = M + (i % 2) * (rw + 10); | |
| 560 | + const y = c.y; | |
| 561 | + card(doc, x, y, rw, 44, SURFACE2); | |
| 562 | + doc.font("SG-Bold").fontSize(9).fillColor(INK) | |
| 563 | + .text(r.value, x + 10, y + 8, { width: rw - 20, height: 11, ellipsis: true, lineBreak: false }); | |
| 564 | + doc.font("JB-Bold").fontSize(5.2).fillColor(INK3) | |
| 565 | + .text(r.label.toUpperCase() + (r.date ? ` · ${r.date}` : ""), x + 10, y + 24, { width: rw - 20, characterSpacing: 0.3, height: 14 }); | |
| 566 | + if (i % 2 === 1 || i === records.length - 1) c.y += 52; | |
| 567 | + }); | |
| 568 | + c.y += 6; | |
| 569 | +} | |
| 570 | + | |
| 571 | +/* ------------------------------- COUVERTURE ------------------------------- */ | |
| 572 | +function cover(doc: Doc, p: EcoPayload, generated: string, modeLabel: string) { | |
| 573 | + doc.rect(0, 0, W, H).fill(PAPER); | |
| 574 | + doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK); | |
| 575 | + kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5); | |
| 576 | + wordmark(doc, 64, 172, 46); | |
| 577 | + doc.font("Inter").fontSize(13).fillColor(INK2).text( | |
| 578 | + "Le portail de l'écosystème ·Ka — rapport consolidé des tableaux de bord statistiques publiés en direct par les plateformes de données du Groupe KA.", | |
| 579 | + 64, 256, { width: W - 168, lineGap: 3 }); | |
| 580 | + const rows: [string, string][] = [ | |
| 581 | + ["Type de rapport", modeLabel], | |
| 582 | + ["Période couverte", p.periodLabel], | |
| 583 | + ["Généré le", `${generated} (heure de l'Est)`], | |
| 584 | + ["Plateforme", "www.groupe-ka.com"], | |
| 585 | + ["Données", `${p.reachable}/${p.total} plateformes jointes · lues à la source`], | |
| 586 | + ]; | |
| 587 | + doc.rect(64, 318, 34, 3).fill(LIME); | |
| 588 | + let y = 336; | |
| 589 | + rows.forEach(([k, v]) => { | |
| 590 | + doc.font("JB-Reg").fontSize(8).fillColor(INK3) | |
| 591 | + .text(k.toUpperCase(), 64, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false }); | |
| 592 | + doc.font("SG-Bold").fontSize(11.5).fillColor(INK) | |
| 593 | + .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false }); | |
| 594 | + y += 24; | |
| 595 | + }); | |
| 596 | + doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK); | |
| 597 | + const by = H - 28 - 64 + 24; | |
| 598 | + doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false }); | |
| 599 | + doc.fillColor(LIME).text("KA", 64 + doc.widthOfString("par Groupe "), by, { lineBreak: false }); | |
| 600 | + doc.font("JB-Bold").fontSize(9.5).fillColor(LIME) | |
| 601 | + .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 }); | |
| 602 | +} | |
| 603 | + | |
| 604 | +/* ------------------------------ PAGE DE FIN ------------------------------ */ | |
| 605 | +function finalPage(doc: Doc) { | |
| 606 | + let y = 60; | |
| 607 | + kicker(doc, "Groupe KA · Contact", M, y); | |
| 608 | + y += 20; | |
| 609 | + doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Coordonnées du Groupe KA", M, y); | |
| 610 | + y += 40; | |
| 611 | + eco.contacts.forEach((c) => { | |
| 612 | + doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(c.email, M, y, { lineBreak: false }); | |
| 613 | + doc.font("JB-Reg").fontSize(8).fillColor(INK3).text(c.role.toUpperCase(), M, y + 17, { characterSpacing: 0.5, lineBreak: false }); | |
| 614 | + y += 42; | |
| 615 | + }); | |
| 616 | + y += 6; | |
| 617 | + doc.font("JB-Bold").fontSize(9.5).fillColor(GREEN) | |
| 618 | + .text("groupe-ka.com — le portail de l'écosystème ·Ka", M, y, { lineBreak: false }); | |
| 619 | + y += 26; | |
| 620 | + doc.rect(M, y, 44, 3).fill(LIME); | |
| 621 | + y += 14; | |
| 622 | + doc.font("Inter").fontSize(10).fillColor(INK2) | |
| 623 | + .text(eco.org.disclaimer + " Données lues à la source, rien d'inventé, tout est traçable.", M, y, { width: 420, lineGap: 3 }); | |
| 624 | + y = doc.y + 16; | |
| 625 | + doc.font("Inter").fontSize(8).fillColor(INK3).text( | |
| 626 | + "Mentions : rapport généré automatiquement à partir des tableaux de bord statistiques publiés par les " + | |
| 627 | + "plateformes de l'écosystème ·Ka (endpoint commun /api/stats/dashboard) au moment indiqué en couverture. " + | |
| 628 | + "Les agrégats (sommes, indices, calendriers) sont calculés sur les seules dates communes aux séries " + | |
| 629 | + "publiées — jamais d'extrapolation ; une plateforme injoignable n'est jamais remplacée par un chiffre " + | |
| 630 | + "inventé. Conditions d'utilisation, politique de confidentialité et protection des renseignements " + | |
| 631 | + "personnels (Loi 25) : groupe-ka.com/conditions · /confidentialite · /loi-25.", | |
| 632 | + M, y, { width: 420, lineGap: 2.5 }); | |
| 633 | + wordmark(doc, M, H - 92, 15); | |
| 634 | +} | |
| 635 | + | |
| 636 | +/* ===================== v3 : rapports personnalisés (SPEC §3bis) ===================== | |
| 637 | + Catalogue de blocs dérivé de l'EcoPayload consolidé + génération d'un PDF | |
| 638 | + composé bloc par bloc (rendu au choix), même gabarit estampillé Groupe-KA. */ | |
| 639 | +export type CatalogBlock = { | |
| 640 | + key: string; section: string; title: string; | |
| 641 | + renders: string[]; default_render: string; count?: number; | |
| 642 | +}; | |
| 643 | +export type CustomBlock = { key: string; render?: string }; | |
| 644 | +export type CustomSpec = { | |
| 645 | + title?: string; period?: string; from?: string; to?: string; | |
| 646 | + blocks?: CustomBlock[]; | |
| 647 | +}; | |
| 648 | + | |
| 649 | +/** EcoPayload consolidé pour une période (mêmes règles que la page /stats). */ | |
| 650 | +export async function getReportPayload(rawPeriod?: string): Promise<EcoPayload> { | |
| 651 | + const period: PeriodKey = isPeriod(rawPeriod) ? rawPeriod : "30j"; | |
| 652 | + const [platforms, live] = await Promise.all([ | |
| 653 | + getEcosystemStats(period), | |
| 654 | + getLiveMetrics().catch(() => null), | |
| 655 | + ]); | |
| 656 | + const periodLabel = | |
| 657 | + platforms.find((p) => p.dash?.period?.label)?.dash?.period?.label ?? | |
| 658 | + (PERIODS.find(([k]) => k === period)?.[1] ?? period); | |
| 659 | + return buildEcoPayload(platforms, { | |
| 660 | + period, | |
| 661 | + periodLabel, | |
| 662 | + connectors: live?.totals.connectors ?? 0, | |
| 663 | + }); | |
| 664 | +} | |
| 665 | + | |
| 666 | +/** Série d'un bloc `series:<id>` (eco-vol agrégé ou tendance d'une plateforme). */ | |
| 667 | +function findSerie(p: EcoPayload, id: string): ChartSerie | null { | |
| 668 | + if (id === "eco-vol") return p.volume?.serie ?? null; | |
| 669 | + if (id.startsWith("pf-")) { | |
| 670 | + const s = p.platforms.find((x) => x.site.id === id.slice(3)); | |
| 671 | + const spark = s?.kpis[0]?.spark ?? []; | |
| 672 | + if (!s || spark.length < 2) return null; | |
| 673 | + return { | |
| 674 | + id, | |
| 675 | + title: `${s.site.wordmark} — tendance (${s.kpis[0].label})`, | |
| 676 | + kind: "line", | |
| 677 | + points: spark, | |
| 678 | + }; | |
| 679 | + } | |
| 680 | + return null; | |
| 681 | +} | |
| 682 | + | |
| 683 | +export function catalogFromDashboard(p: EcoPayload): CatalogBlock[] { | |
| 684 | + const out: CatalogBlock[] = []; | |
| 685 | + const add = (key: string, title: string, renders: string[], def?: string, count?: number) => | |
| 686 | + out.push({ key, section: key.split(":")[0], title, renders, | |
| 687 | + default_render: def ?? renders[0], | |
| 688 | + ...(count !== undefined ? { count } : {}) }); | |
| 689 | + if (p.banner?.length) add("kpis", "Indicateurs clés de l'écosystème", ["cards", "table"], undefined, p.banner.length); | |
| 690 | + if (p.gauges?.length) add("gauges", "Couverture du hub (jauges)", ["gauges", "table"], undefined, p.gauges.length); | |
| 691 | + if ((p.volume?.serie.points?.length ?? 0) >= 2) | |
| 692 | + add("series:eco-vol", p.volume!.serie.title, ["line", "area", "bar", "table"], "area", p.volume!.serie.points.length); | |
| 693 | + for (const s of p.platforms) { | |
| 694 | + const spark = s.kpis[0]?.spark ?? []; | |
| 695 | + if (!s.ok || spark.length < 2) continue; | |
| 696 | + add(`series:pf-${s.site.id}`, `${s.site.wordmark} — tendance (${s.kpis[0].label})`, | |
| 697 | + ["line", "area", "bar", "table"], "line", spark.length); | |
| 698 | + } | |
| 699 | + if (p.growthIndex?.ms.series?.length) | |
| 700 | + add("multiseries:eco-growth", p.growthIndex.ms.title, ["lines", "table"], undefined, p.growthIndex.ms.series.length); | |
| 701 | + if (p.stacked?.st.points?.length) | |
| 702 | + add("stacked:eco-stack", p.stacked.st.title, ["stacked", "table"], undefined, p.stacked.st.keys?.length); | |
| 703 | + if (p.ranking?.length) | |
| 704 | + add("breakdowns:classement", "Classement des plateformes par volume", ["donut", "bars", "table"], "bars", p.ranking.length); | |
| 705 | + for (const s of p.platforms) { | |
| 706 | + if (!s.ok || !s.breakdown?.items?.length) continue; | |
| 707 | + add(`breakdowns:pf-${s.site.id}`, `${s.site.wordmark} — ${s.breakdown.title}`, | |
| 708 | + ["donut", "bars", "table"], s.breakdown.kind === "donut" ? "donut" : "bars", s.breakdown.items.length); | |
| 709 | + } | |
| 710 | + for (const s of p.platforms) { | |
| 711 | + if (!s.ok || !s.geo?.items?.length) continue; | |
| 712 | + add(`geo:pf-${s.site.id}`, `${s.site.wordmark} — ${s.geo.title}`, ["bars", "table"], undefined, s.geo.items.length); | |
| 713 | + } | |
| 714 | + if (p.heat?.cells?.length) add("heatmap", p.heat.title, ["heatmap", "table"]); | |
| 715 | + if (p.hourly?.cells?.length) add("hourly", p.hourly.title, ["heatmap", "table"]); | |
| 716 | + if (p.table?.rows?.length) add(`tables:${p.table.id}`, p.table.title, ["table"], undefined, p.table.rows.length); | |
| 717 | + if (p.records?.length) add("records", "Records & faits marquants", ["cards", "table"], undefined, p.records.length); | |
| 718 | + return out; | |
| 719 | +} | |
| 720 | + | |
| 721 | +type AnyRow = (string | number | null)[]; | |
| 722 | +const seriesAsTable = (s: ChartSerie) => ({ | |
| 723 | + title: s.title, | |
| 724 | + columns: ["Date", (s.unit ?? "Valeur")] as string[], | |
| 725 | + rows: (s.points ?? []).map((pt) => [pt.t, pt.v] as AnyRow), | |
| 726 | +}); | |
| 727 | + | |
| 728 | +function renderCustomBlock(c: Cursor, p: EcoPayload, key: string, render: string, title: string) { | |
| 729 | + const [section, id] = [key.split(":")[0], key.split(":").slice(1).join(":")]; | |
| 730 | + const mark = () => { c.ensure(160); c.sections.push({ label: title, page: c.page() }); }; | |
| 731 | + if (section === "kpis") { | |
| 732 | + if (render === "table") { | |
| 733 | + tablePdf(c, { title: "Indicateurs clés de l'écosystème", columns: ["Indicateur", "Valeur", "Δ %"], | |
| 734 | + rows: p.banner.map((k) => [k.label, typeof k.value === "number" ? num(k.value) + (k.unit ? ` ${k.unit}` : "") : String(k.value), | |
| 735 | + k.delta_pct == null ? "" : `${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct} %`] as AnyRow) }, 400); | |
| 736 | + } else { c.section("Indicateurs clés de l'écosystème"); kpiGridPdf(c, p.banner); } | |
| 737 | + } else if (section === "gauges") { | |
| 738 | + if (render === "table") { | |
| 739 | + tablePdf(c, { title: "Couverture du hub", columns: ["Mesure", "Valeur", "Max", "Part"], | |
| 740 | + rows: p.gauges.map((g) => [g.label, `${num(g.value)}${g.unit ? ` ${g.unit}` : ""}`, num(g.max), | |
| 741 | + `${Math.round((100 * g.value) / (g.max || 1))} %`] as AnyRow) }, 400); | |
| 742 | + } else { c.section("Couverture du hub"); gaugesPdf(c, p.gauges); } | |
| 743 | + } else if (section === "records") { | |
| 744 | + if (render === "table") { | |
| 745 | + tablePdf(c, { title: "Records & faits marquants", columns: ["Fait marquant", "Valeur", "Date"], | |
| 746 | + rows: p.records.map((r) => [r.label, r.value, r.date ?? ""] as AnyRow) }, 400); | |
| 747 | + } else recordsPdf(c, p.records); | |
| 748 | + } else if (section === "series") { | |
| 749 | + const s = findSerie(p, id); | |
| 750 | + if (!s) return; | |
| 751 | + if (render === "table") tablePdf(c, seriesAsTable(s), 400); | |
| 752 | + else { | |
| 753 | + mark(); | |
| 754 | + linePdf(c, { ...s, kind: (["line", "area", "bar"].includes(render) ? render : s.kind) as ChartSerie["kind"] }); | |
| 755 | + statLine(c, s); | |
| 756 | + } | |
| 757 | + } else if (section === "multiseries") { | |
| 758 | + const ms: ChartMultiSerie | undefined = id === "eco-growth" ? p.growthIndex?.ms : undefined; | |
| 759 | + if (!ms) return; | |
| 760 | + if (render === "table") { | |
| 761 | + const labels = ms.series.slice(0, 4).map((x) => x.label); | |
| 762 | + const byT = new Map<string, Record<string, number>>(); | |
| 763 | + for (const se of ms.series.slice(0, 4)) | |
| 764 | + for (const pt of se.points) { | |
| 765 | + const m = byT.get(pt.t) ?? {}; | |
| 766 | + m[se.label] = pt.v; byT.set(pt.t, m); | |
| 767 | + } | |
| 768 | + tablePdf(c, { title: ms.title, columns: ["Date", ...labels], | |
| 769 | + rows: [...byT.keys()].sort().map((t) => [t, ...labels.map((l) => byT.get(t)?.[l] ?? "")] as AnyRow) }, 400); | |
| 770 | + } else { mark(); multiLinePdf(c, ms); } | |
| 771 | + } else if (section === "stacked") { | |
| 772 | + const st: ChartStacked | undefined = id === "eco-stack" ? p.stacked?.st : undefined; | |
| 773 | + if (!st) return; | |
| 774 | + if (render === "table") { | |
| 775 | + const keys = (st.keys ?? []).slice(0, 6); | |
| 776 | + tablePdf(c, { title: st.title, columns: ["Date", ...keys, "Total"], | |
| 777 | + rows: st.points.map((pt) => { | |
| 778 | + const vs = keys.map((_, j) => pt.values[j] ?? 0); | |
| 779 | + return [pt.t, ...vs, vs.reduce((a, b) => a + b, 0)] as AnyRow; | |
| 780 | + }) }, 400); | |
| 781 | + } else { mark(); stackedPdf(c, st); } | |
| 782 | + } else if (section === "breakdowns") { | |
| 783 | + let items: BreakItem[] = []; | |
| 784 | + if (id === "classement") items = p.ranking ?? []; | |
| 785 | + else if (id.startsWith("pf-")) items = p.platforms.find((x) => x.site.id === id.slice(3))?.breakdown?.items ?? []; | |
| 786 | + if (!items.length) return; | |
| 787 | + if (render === "table") { | |
| 788 | + tablePdf(c, { title, columns: ["Libellé", "Valeur"], | |
| 789 | + rows: items.map((it) => [it.label, it.value] as AnyRow) }, 400); | |
| 790 | + } else { | |
| 791 | + mark(); | |
| 792 | + if (render === "donut") donutPdf(c, title, items); | |
| 793 | + else hbarsPdf(c, title, items); | |
| 794 | + } | |
| 795 | + } else if (section === "geo") { | |
| 796 | + const items = id.startsWith("pf-") | |
| 797 | + ? p.platforms.find((x) => x.site.id === id.slice(3))?.geo?.items ?? [] | |
| 798 | + : []; | |
| 799 | + if (!items.length) return; | |
| 800 | + if (render === "table") { | |
| 801 | + tablePdf(c, { title, columns: ["Zone", "Valeur"], | |
| 802 | + rows: items.map((it) => [it.label, it.value] as AnyRow) }, 400); | |
| 803 | + } else { mark(); hbarsPdf(c, title, items); } | |
| 804 | + } else if (section === "heatmap") { | |
| 805 | + if (!p.heat?.cells?.length) return; | |
| 806 | + if (render === "table") { | |
| 807 | + const cells = [...p.heat.cells].sort((a, b) => b.value - a.value).slice(0, 40); | |
| 808 | + tablePdf(c, { title: `${p.heat.title} — jours les plus chargés`, | |
| 809 | + columns: ["Date", "Valeur"], rows: cells.map((x) => [x.date, x.value] as AnyRow) }, 400); | |
| 810 | + } else { mark(); heatmapPdf(c, p.heat); } | |
| 811 | + } else if (section === "hourly") { | |
| 812 | + if (!p.hourly?.cells?.length) return; | |
| 813 | + if (render === "table") { | |
| 814 | + const cells = [...p.hourly.cells].sort((a, b) => b.value - a.value).slice(0, 40); | |
| 815 | + tablePdf(c, { title: `${p.hourly.title} — créneaux les plus actifs`, | |
| 816 | + columns: ["Jour", "Heure", "Valeur"], | |
| 817 | + rows: cells.map((x) => [DOW_FR[x.dow] ?? String(x.dow), `${x.hour} h`, x.value] as AnyRow) }, 400); | |
| 818 | + } else { mark(); hourlyPdf(c, p.hourly); } | |
| 819 | + } else if (section === "tables") { | |
| 820 | + if (String(p.table?.id) === id && p.table.rows?.length) | |
| 821 | + tablePdf(c, p.table, 400); | |
| 822 | + } | |
| 823 | +} | |
| 824 | + | |
| 825 | +/** PDF personnalisé — lève Error("aucun-bloc") si la composition est vide. */ | |
| 826 | +export async function buildCustomReport(spec: CustomSpec, payload: EcoPayload): Promise<Buffer> { | |
| 827 | + const cat = new Map(catalogFromDashboard(payload).map((b) => [b.key, b])); | |
| 828 | + const blocks = (spec.blocks ?? []) | |
| 829 | + .filter((b): b is CustomBlock => !!b && typeof b === "object" && cat.has(String(b.key))) | |
| 830 | + .slice(0, 40); | |
| 831 | + if (!blocks.length) throw new Error("aucun-bloc"); | |
| 832 | + const title = String(spec.title ?? "").slice(0, 80).trim(); | |
| 833 | + const label = title ? `Rapport personnalisé — ${title}` : "Rapport personnalisé"; | |
| 834 | + PERIOD_LABEL = payload.periodLabel; | |
| 835 | + | |
| 836 | + const generated = new Date().toLocaleString("fr-CA", { | |
| 837 | + timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", | |
| 838 | + hour: "2-digit", minute: "2-digit", hour12: false, | |
| 839 | + }).replace(",", " ·"); | |
| 840 | + const doc = new PDFDocument({ | |
| 841 | + size: "A4", margin: 0, bufferPages: true, | |
| 842 | + info: { Title: `Groupe KA · Portail de l'écosystème — ${label}`, Author: "Groupe KA — groupe-ka.com" }, | |
| 843 | + }); | |
| 844 | + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); | |
| 845 | + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); | |
| 846 | + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); | |
| 847 | + doc.registerFont("Inter", F("Inter-Regular.ttf")); | |
| 848 | + const chunks: Buffer[] = []; | |
| 849 | + doc.on("data", (b: Buffer) => chunks.push(b)); | |
| 850 | + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); | |
| 851 | + | |
| 852 | + cover(doc, payload, generated, label); | |
| 853 | + doc.addPage({ size: "A4", margin: 0 }); // page 2 : sommaire (post-passe) | |
| 854 | + doc.addPage({ size: "A4", margin: 0 }); | |
| 855 | + chrome(doc); | |
| 856 | + const c = new Cursor(doc); | |
| 857 | + for (const blk of blocks) { | |
| 858 | + const b = cat.get(String(blk.key))!; | |
| 859 | + const render = b.renders.includes(String(blk.render ?? "")) ? String(blk.render) : b.default_render; | |
| 860 | + renderCustomBlock(c, payload, b.key, render, b.title); | |
| 861 | + } | |
| 862 | + doc.addPage({ size: "A4", margin: 0 }); | |
| 863 | + chrome(doc); | |
| 864 | + finalPage(doc); | |
| 865 | + | |
| 866 | + const range = doc.bufferedPageRange(); | |
| 867 | + const total = range.count; | |
| 868 | + doc.switchToPage(1); | |
| 869 | + chrome(doc); | |
| 870 | + let y = TOP + 4; | |
| 871 | + kicker(doc, label, M, y); | |
| 872 | + y += 20; | |
| 873 | + doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); | |
| 874 | + y += 40; | |
| 875 | + const entries: [string, string][] = [ | |
| 876 | + ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]), | |
| 877 | + ["Coordonnées du Groupe KA & mentions", String(total)], | |
| 878 | + ]; | |
| 879 | + entries.slice(0, 16).forEach(([lab, pg]) => { | |
| 880 | + doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9"); | |
| 881 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(lab, M, y, { width: CW - 70, height: 26, ellipsis: true }); | |
| 882 | + doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); | |
| 883 | + y += 34; | |
| 884 | + }); | |
| 885 | + for (let i = 1; i < total; i++) { | |
| 886 | + doc.switchToPage(i); | |
| 887 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK) | |
| 888 | + .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false }); | |
| 889 | + } | |
| 890 | + doc.end(); | |
| 891 | + return done; | |
| 892 | +} | |
| 893 | + | |
| 894 | +/** Nom de fichier normalisé Groupe-KA (SPEC §3bis). */ | |
| 895 | +export function customReportFilename(period: string): string { | |
| 896 | + const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" }); | |
| 897 | + return `groupe-ka_groupe-ka_stats_${period}_personnalise_${today}.pdf`; | |
| 898 | +} | |
| 899 | ||