diff --git a/condition-api/src/condition_api/services/condition_service.py b/condition-api/src/condition_api/services/condition_service.py index 1f2cc5a..528c9be 100644 --- a/condition-api/src/condition_api/services/condition_service.py +++ b/condition-api/src/condition_api/services/condition_service.py @@ -942,9 +942,13 @@ def _process_internal_conditions(condition_data): "year_issued": row.year_issued, "effective_document_id": row.effective_document_id, "source_document": row.amendment_name if row.amendment_name - and row.condition_type == ConditionType.ADD else row.document_label + and row.condition_type == ConditionType.ADD else row.document_label, + "subconditions": [] } + for cond_id in conditions_map: + ConditionService._populate_subconditions(conditions_map, cond_id) + return ProjectDocumentConditionSchema().dump( { "project_name": condition_data[0].project_name, diff --git a/condition-web/package-lock.json b/condition-web/package-lock.json index 956181a..82b9fa7 100644 --- a/condition-web/package-lock.json +++ b/condition-web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@fontsource/inter": "^5.2.8", "@hello-pangea/dnd": "^17.0.0", "@hookform/resolvers": "^3.9.0", "@mui/icons-material": "^5.15.21", @@ -26,6 +27,7 @@ "date-fns": "^2.28.0", "date-fns-tz": "^1.3.4", "epic.theme": "latest", + "jspdf": "^4.2.0", "jwt-decode": "^4.0.0", "keycloak-js": "^25.0.1", "oidc-client-ts": "^3.0.1", @@ -1777,12 +1779,10 @@ "peer": true }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -2408,7 +2408,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "aix" @@ -2424,7 +2423,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -2440,7 +2438,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2456,7 +2453,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2472,7 +2468,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2488,7 +2483,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2504,7 +2498,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -2520,7 +2513,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -2536,7 +2528,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2552,7 +2543,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2568,7 +2558,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2584,7 +2573,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2600,7 +2588,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2616,7 +2603,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2632,7 +2618,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2648,7 +2633,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2664,7 +2648,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2680,7 +2663,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -2696,7 +2678,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -2712,7 +2693,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -2728,7 +2708,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2744,7 +2723,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2760,7 +2738,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2908,6 +2885,15 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hello-pangea/dnd": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-17.0.0.tgz", @@ -3584,7 +3570,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -3597,7 +3582,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -3610,7 +3594,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -3623,7 +3606,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -3636,7 +3618,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3649,7 +3630,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3662,7 +3642,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3675,7 +3654,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3688,7 +3666,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3701,7 +3678,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3714,7 +3690,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3727,7 +3702,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3740,7 +3714,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3753,7 +3726,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3766,7 +3738,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3779,7 +3750,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -4300,6 +4270,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -4310,6 +4286,13 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", @@ -4355,6 +4338,13 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", @@ -5282,6 +5272,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5504,6 +5504,26 @@ } ] }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -5778,6 +5798,18 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.38.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz", @@ -5835,6 +5867,16 @@ "tiny-invariant": "^1.0.6" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -6157,6 +6199,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", @@ -6770,6 +6822,17 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", @@ -6795,6 +6858,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7367,6 +7436,20 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -7423,7 +7506,7 @@ "version": "4.3.7", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true + "devOptional": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -7492,6 +7575,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7960,6 +8049,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", + "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -8882,6 +8988,12 @@ "node": ">=8" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8960,7 +9072,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "devOptional": true }, "node_modules/picocolors": { "version": "1.0.1", @@ -9470,6 +9582,16 @@ } ] }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -9657,9 +9779,11 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -9823,6 +9947,16 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rifm": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.12.1.tgz", @@ -9944,7 +10078,7 @@ "version": "1.77.8", "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", - "dev": true, + "devOptional": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -10250,6 +10384,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10333,6 +10477,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -10459,6 +10613,16 @@ "node": "*" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10779,6 +10943,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -10961,21 +11135,6 @@ } } }, - "node_modules/vite-tsconfig-paths/node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", diff --git a/condition-web/package.json b/condition-web/package.json index 3c88a7d..f077f0f 100644 --- a/condition-web/package.json +++ b/condition-web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@fontsource/inter": "^5.2.8", "@hello-pangea/dnd": "^17.0.0", "@hookform/resolvers": "^3.9.0", "@mui/icons-material": "^5.15.21", @@ -30,6 +31,7 @@ "date-fns": "^2.28.0", "date-fns-tz": "^1.3.4", "epic.theme": "latest", + "jspdf": "^4.2.0", "jwt-decode": "^4.0.0", "keycloak-js": "^25.0.1", "oidc-client-ts": "^3.0.1", diff --git a/condition-web/src/components/ConsolidatedConditions/index.tsx b/condition-web/src/components/ConsolidatedConditions/index.tsx index 2c63a80..0a9a2bb 100644 --- a/condition-web/src/components/ConsolidatedConditions/index.tsx +++ b/condition-web/src/components/ConsolidatedConditions/index.tsx @@ -2,16 +2,19 @@ import { useState, useEffect } from "react"; import { useNavigate } from "@tanstack/react-router"; import { BCDesignTokens } from "epic.theme"; import { ConditionModel } from "@/models/Condition"; -import { Box, FormControlLabel, Grid, styled, Stack, Switch, Typography } from "@mui/material"; +import { Box, Button, FormControlLabel, Grid, styled, Stack, Switch, Typography } from "@mui/material"; import { ContentBoxSkeleton } from "../Shared/ContentBox/ContentBoxSkeleton"; import { ContentBox } from "../Shared/ContentBox"; import ConditionTable from "../Conditions/ConditionsTable"; import { DocumentStatus } from "@/models/Document"; import DocumentStatusChip from "../Projects/DocumentStatusChip"; import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'; +import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'; import ConsolidatedConditionFilters from "@/components/Filters/ConsolidatedConditionFilters"; import { useConditionFilters } from "@/components/Filters/conditionFilterStore"; import { CONDITION_STATUS, ConditionStatus } from "@/models/Condition"; +import { generateConsolidatedConditionsPDF } from "@/utils/pdfExport"; +import eaoLogo from "@/assets/images/EAO_Logo.png"; export const CardInnerBox = styled(Box)({ display: "flex", @@ -47,6 +50,20 @@ export const ConsolidatedConditions = ({ const [isToggled, setIsToggled] = useState(true); const { filters } = useConditionFilters(); + const [isExporting, setIsExporting] = useState(false); + + const handleExportPDF = async () => { + setIsExporting(true); + try { + await generateConsolidatedConditionsPDF( + filteredConditions || [], + projectName, + eaoLogo + ); + } finally { + setIsExporting(false); + } + }; const filteredConditions = conditions?.filter((condition) => { const matchesSearch = filters.search_text @@ -153,19 +170,41 @@ export const ConsolidatedConditions = ({ - {consolidationLevel != 'project' && - - - } - label="View Consolidated Conditions" - labelPlacement="end" - /> - } + + {consolidationLevel != 'project' && ( + + } + label="View Consolidated Conditions" + labelPlacement="end" + /> + )} + + PH - MB - DISC_H - 3) newPage(); +} + +function setFont( + size: number, + style: "normal" | "bold" | "italic" | "bolditalic" = "normal", + color: RGB = C.DARK +) { + _doc.setFontSize(size); + _doc.setFont("helvetica", style); + _doc.setTextColor(color[0], color[1], color[2]); +} + +/** Line height in mm for a given pt size */ +function lh(pt: number, mult = 1.35): number { + return pt * 0.3528 * mult; +} + +function drawRect( + x: number, y: number, w: number, h: number, + fill?: RGB, stroke?: RGB, strokeW = 0.2 +) { + if (fill) _doc.setFillColor(fill[0], fill[1], fill[2]); + if (stroke) { _doc.setDrawColor(stroke[0], stroke[1], stroke[2]); _doc.setLineWidth(strokeW); } + _doc.rect(x, y, w, h, fill && stroke ? "FD" : fill ? "F" : "D"); +} + +function drawHRule(x1: number, y: number, x2: number, color: RGB = C.LIGHT_GRAY, w = 0.2) { + _doc.setDrawColor(color[0], color[1], color[2]); + _doc.setLineWidth(w); + _doc.line(x1, y, x2, y); +} + +// ─── Inline chip helpers ─────────────────────────────────────────────────────── + +const CHIP_H = 7.94; // mm (30px = 5+20+5px padding+line-height+padding) +const CHIP_PL = 3.44; // left padding mm (13px) +const CHIP_PR = 3.27; // right padding mm (12.359px) +const CHIP_FONT = 10.5; // pt (14px) +const CHIP_R = 1.06; // border-radius mm (4px) +// Text baseline: chip top + top-pad(1.32) + line-height(5.29) - descender(~1.3) ≈ top + 5.3 +const CHIP_TEXT_DY = 5.3; + +/** Returns the chip width in mm */ +function chipWidth(label: string): number { + _doc.setFontSize(CHIP_FONT); + _doc.setFont("helvetica", "normal"); + return _doc.getTextWidth(label) + CHIP_PL + CHIP_PR; +} + +/** + * Draw a status chip (Approved / Awaiting Approval). + * x, y = top-left corner of chip. + */ +function drawStatusChip( + label: string, + isApproved: boolean | undefined, + x: number, + y: number +) { + const border = isApproved === true ? C.GREEN_BORDER : C.GOLD_BORDER; + const bg = isApproved === true ? C.GREEN_BG : C.GOLD_BG; + const textColor = isApproved === true ? C.GREEN_TEXT : C.GOLD_TEXT; + const cw = chipWidth(label); + _doc.setFillColor(bg[0], bg[1], bg[2]); + _doc.setDrawColor(border[0], border[1], border[2]); + _doc.setLineWidth(0.35); + _doc.roundedRect(x, y, cw, CHIP_H, CHIP_R, CHIP_R, "FD"); + setFont(CHIP_FONT, "normal", textColor); + _doc.text(label, x + CHIP_PL, y + CHIP_TEXT_DY); +} + +/** + * Draw a blue "Amended Version" chip. + * "Amended Version:" prefix is bold-italic; the amendment name is normal weight. + * x, y = top-left corner of chip. + */ +function drawAmendChip(label: string, x: number, y: number) { + const PREFIX = "Amended Version: "; + const hasPrefix = label.startsWith(PREFIX); + const suffix = hasPrefix ? label.slice(PREFIX.length) : label; + + // Measure each segment with its own font style for accurate width + _doc.setFontSize(CHIP_FONT); + _doc.setFont("helvetica", "bolditalic"); + const prefixW = hasPrefix ? _doc.getTextWidth(PREFIX) : 0; + _doc.setFont("helvetica", "normal"); + const suffixW = _doc.getTextWidth(hasPrefix ? suffix : label); + + const cw = prefixW + suffixW + CHIP_PL + CHIP_PR; + _doc.setFillColor(C.BLUE_BG[0], C.BLUE_BG[1], C.BLUE_BG[2]); + _doc.setDrawColor(C.BC_BLUE[0], C.BC_BLUE[1], C.BC_BLUE[2]); + _doc.setLineWidth(0.35); + _doc.roundedRect(x, y, cw, CHIP_H, CHIP_R, CHIP_R, "FD"); + + const textY = y + CHIP_TEXT_DY; + if (hasPrefix) { + setFont(CHIP_FONT, "bolditalic", C.BC_BLUE); + _doc.text(PREFIX, x + CHIP_PL, textY); + setFont(CHIP_FONT, "normal", C.BC_BLUE); + _doc.text(suffix, x + CHIP_PL + prefixW, textY); + } else { + setFont(CHIP_FONT, "normal", C.BC_BLUE); + _doc.text(label, x + CHIP_PL, textY); + } +} + +// ─── Header section ──────────────────────────────────────────────────────────── + +function addHeader( + projectName: string, + conditions: ConditionModel[], + eaoLogoData?: string +) { + _y = MT; + + // ── Logo (left) and title (right) ─────────────────────────────────────────── + const titleAreaW = CW * 0.55; + + // EAO logo only (already contains BC logo) — 516×96px → 70×13mm (ratio 5.375:1) + const LOGO_W = 70; + const LOGO_H = 13; + if (eaoLogoData) { + try { _doc.addImage(eaoLogoData, "PNG", ML, _y + 2, LOGO_W, LOGO_H); } catch { /* ignore */ } + } + + // "Consolidated Conditions" — right-aligned + _doc.setFont("helvetica", "bold"); + _doc.setFontSize(22); + _doc.setTextColor(C.DARK[0], C.DARK[1], C.DARK[2]); + _doc.text("Consolidated Conditions", PW - MR, _y + 8, { align: "right" }); + + // Project name — right-aligned under title + _doc.setFontSize(15); + _doc.setFont("helvetica", "normal"); + _doc.setTextColor(C.SUBTITLE[0], C.SUBTITLE[1], C.SUBTITLE[2]); + const projLines = _doc.splitTextToSize(projectName, titleAreaW) as string[]; + const projLineH = lh(15, 1.3); + for (let i = 0; i < projLines.length; i++) { + _doc.text(projLines[i], PW - MR, _y + 17 + i * projLineH, { align: "right" }); + } + + // Status chip — right-aligned below project name + const allApproved = conditions.every((c) => c.is_approved === true); + const overallLabel = allApproved ? "Approved" : "Awaiting Approval"; + const chipY = _y + 17 + projLines.length * projLineH - 2; + const chipX = PW - MR - chipWidth(overallLabel); + drawStatusChip(overallLabel, allApproved, chipX, chipY); + + _y += 17 + projLines.length * projLineH + CHIP_H + 6; // dynamic row height + gap + + // ── Horizontal separator ────────────────────────────────────────────────────── + drawHRule(ML, _y, PW - MR, C.SEPARATOR, 0.5); + _y += 9; // 33px gap after separator → stats + + // ── Stats ───────────────────────────────────────────────────────────────────── + const today = format(new Date(), "EEEE, MMMM d, yyyy"); + const totalConditions = conditions.length; + + // Collect unique amendment names from all conditions + const amendmentSet = new Set(); + conditions.forEach((c) => { + if (c.amendment_names) { + c.amendment_names.split(",").forEach((a) => { + const trimmed = a.trim(); + if (trimmed) amendmentSet.add(trimmed); + }); + } + }); + const amendmentList = Array.from(amendmentSet).join(", "); + + const statsRows: [string, string][] = [ + ["Generated on:", today], + ["Total Conditions:", String(totalConditions)], + ...(amendmentList ? [["Included Amendments:", amendmentList] as [string, string]] : []), + ]; + + const statLabelW = 50; + for (const [label, value] of statsRows) { + setFont(10.5, "bold", C.DARK); + _doc.text(label, ML, _y); + setFont(10.5, "normal", C.DARK); + const valueLines = _doc.splitTextToSize(value, CW - statLabelW) as string[]; + for (let i = 0; i < valueLines.length; i++) { + _doc.text(valueLines[i], ML + statLabelW, _y + i * lh(10.5, 1.3)); + } + _y += valueLines.length * lh(10.5, 1.3) + 2.1; // 8px row gap + } + + _y += 8.47; // 32px gap stats → info box + + // ── Info / disclaimer box ───────────────────────────────────────────────────── + // Figma: padding 16px top / 16px right / 16px bottom / 20px left, gap 12px between paras + // border-l-4 (4px = 1.06mm) + padding-left 20px = 5.29mm → text at ML + 6.35mm + const BOX_ACCENT = 1.06; // 4px border-left + const BOX_PL = 5.29; // 20px padding-left (after accent) + const BOX_PR = 4.23; // 16px padding-right + const BOX_PT_TEXT = 7.0; // baseline offset: 16px pad + cap-height(~2.7mm) so visual top aligns + const BOX_PB = 4.23; // 16px padding-bottom (same as top) + const BOX_GAP = 3.17; // 12px gap between paragraphs + const BOX_TEXT_X = ML + BOX_ACCENT + BOX_PL; + const BOX_TEXT_W = CW - BOX_ACCENT - BOX_PL - BOX_PR; + const boxLineH = lh(10.5, 1.4); + + const para1 = + "This Consolidated Conditions document contains the most recent condition information as " + + "entered in the Condition Repository. This is not an official or enforceable document and " + + "should be used supplementary to official documents."; + const para2 = + "Note: Conditions with the status \u2018Awaiting Approval\u2019 have not been approved by a staff member."; + + setFont(10.5, "normal", C.SUBTITLE); + const para1Lines = _doc.splitTextToSize(para1, BOX_TEXT_W) as string[]; + const para2Lines = _doc.splitTextToSize(para2, BOX_TEXT_W) as string[]; + + const boxH = BOX_PT_TEXT + + para1Lines.length * boxLineH + + BOX_GAP + + para2Lines.length * boxLineH + + BOX_PB; + + const boxStartY = _y; + + // Background + left accent bar + drawRect(ML, _y, CW, boxH, C.INFO_BOX_BG); + _doc.setFillColor(C.BLUE_ACCENT[0], C.BLUE_ACCENT[1], C.BLUE_ACCENT[2]); + _doc.rect(ML, _y, BOX_ACCENT, boxH, "F"); + + _y += BOX_PT_TEXT; + + for (const line of para1Lines) { + _doc.text(line, BOX_TEXT_X, _y); + _y += boxLineH; + } + + _y += BOX_GAP; + + // "Note:" bold, rest normal — matches Figma bold+normal inline style + setFont(10.5, "bold", C.SUBTITLE); + const notePrefix = "Note: "; + const notePrefixW = _doc.getTextWidth(notePrefix); + _doc.text(notePrefix, BOX_TEXT_X, _y); + setFont(10.5, "normal", C.SUBTITLE); + const noteBody = para2Lines[0].replace(/^Note: /, ""); + _doc.text(noteBody, BOX_TEXT_X + notePrefixW, _y); + for (let i = 1; i < para2Lines.length; i++) { + _y += boxLineH; + _doc.text(para2Lines[i], BOX_TEXT_X, _y); + } + + _y = boxStartY + boxH + 12.7; // bottom of box + gap to first condition +} + +// ─── Subconditions ───────────────────────────────────────────────────────────── + +function renderSubconditions( + subconditions: SubconditionModel[], + baseX: number, + level: number +) { + const identW = level === 0 ? 12 : 7; // level-1 letter col: ~3.7mm + 3.2mm gap + const textX = baseX + identW; + const textW = CW - (baseX - ML) - identW; + // Figma: 16px/400/#101828, line-height 24px = 6.35mm + const lineH = 6.35; // 24px exact line-height + const itemGap = level === 0 ? 6.4 : 2.1; // 24px between numbered, 8px between lettered + + const sorted = [...subconditions].sort((a, b) => a.sort_order - b.sort_order); + + for (const sub of sorted) { + setFont(12, "normal", C.CONTENT); + const lines = _doc.splitTextToSize(sub.subcondition_text || "", textW) as string[]; + const neededH = lines.length * lineH + itemGap; + + ensureSpace(neededH + 1); + + // Identifier (bold, same color) + setFont(12, "bold", C.CONTENT); + _doc.text(sub.subcondition_identifier || "", baseX, _y); + + // Text (first line shares y with identifier) + setFont(12, "normal", C.CONTENT); + for (let i = 0; i < lines.length; i++) { + _doc.text(lines[i], textX, _y); + _y += lineH; + } + _y += itemGap; // 24px between numbered items, 8px between lettered + + if (sub.subconditions && sub.subconditions.length > 0) { + renderSubconditions(sub.subconditions, baseX + 8.5, level + 1); // 32px indent + } + } +} + +// ─── Single condition block ───────────────────────────────────────────────────── + +function renderCondition(condition: ConditionModel) { + const isApproved = condition.is_approved === true; + const statusLabel = isApproved ? "Approved" : "Awaiting Approval"; + const hasAmendment = !!(condition.amendment_names?.trim()); + const amendLabel = hasAmendment ? `Amended Version: ${condition.amendment_names}` : ""; + + // Estimate minimum space needed to avoid orphaned heading + ensureSpace(32); + + // ── Condition heading ───────────────────────────────────────────────────────── + // Figma: 24px/500/line-height 32px → 18pt normal, lineH = 8.47mm + const heading = `${condition.condition_number}. ${condition.condition_name}`; + const headingPt = 18; + const headingLineH = 8.47; // 32px exact line-height from Figma + + setFont(headingPt, "normal", C.DARK); + const headingLines = _doc.splitTextToSize(heading, CW) as string[]; + + ensureSpace(headingLines.length * headingLineH + CHIP_H + 8); + + for (let i = 0; i < headingLines.length; i++) { + _doc.text(headingLines[i], ML, _y); + _y += i < headingLines.length - 1 ? headingLineH : 3; // 3mm gap before chips + } + + // ── Amended chip (if present) — below heading ──────────────────────────────── + if (hasAmendment) { + drawAmendChip(amendLabel, ML, _y); + _y += CHIP_H + 5; + } else { + _y += 3; // small gap before metadata card when no chip + } + + // ── Metadata card ───────────────────────────────────────────────────────────── + const CARD_PAD = 4.5; // 17px padding + const CARD_H = 14.29; // 54px minimum height + const CARD_R = 1.06; // 4px border-radius + const cardTextX = ML + CARD_PAD; + const cardTextW = CW - CARD_PAD * 2; + + const sourceText = condition.source_document || ""; + const yearText = String(condition.year_issued ?? ""); + + // Measure widths BEFORE drawing (needed for dynamic height) + setFont(10.5, "normal", C.GRAY); + const yearLabelW = _doc.getTextWidth("Year Condition Issued:") + 1.5; // +1.5mm explicit space + const srcLabelW = _doc.getTextWidth("Source Document:") + 1.5; // +1.5mm explicit space + const rowLineH = 5.29; // 20px line-height + const srcLines = _doc.splitTextToSize(sourceText, cardTextW - srcLabelW) as string[]; + + // Dynamic height: row1 (year) + row2 (source, may wrap) + padding + const dynamicCardH = Math.max( + CARD_H, + CARD_PAD + rowLineH + 3 + srcLines.length * rowLineH + CARD_PAD + ); + + ensureSpace(dynamicCardH + 4); + const cardY = _y; + const row1Y = cardY + CARD_PAD + 4.0; // Year row baseline + const row2Y = row1Y + rowLineH + 3; // Source row baseline + + // Card background + _doc.setFillColor(C.CARD_BG[0], C.CARD_BG[1], C.CARD_BG[2]); + _doc.setDrawColor(C.CARD_BORDER[0], C.CARD_BORDER[1], C.CARD_BORDER[2]); + _doc.setLineWidth(0.2); + _doc.roundedRect(ML, cardY, CW, dynamicCardH, CARD_R, CARD_R, "FD"); + + // Row 1 — Year Condition Issued (left) + status chip (right) + setFont(10.5, "normal", C.GRAY); + _doc.text("Year Condition Issued:", cardTextX, row1Y); + setFont(10.5, "normal", C.CONTENT); + _doc.text(yearText, cardTextX + yearLabelW, row1Y); + + // Status chip — vertically aligned with row 1 text + const statusChipX = ML + CW - CARD_PAD - chipWidth(statusLabel); + const statusChipY = row1Y - CHIP_TEXT_DY; + drawStatusChip(statusLabel, isApproved, statusChipX, statusChipY); + + // Row 2 — Source Document (stacked below Year, full width) + setFont(10.5, "normal", C.GRAY); + _doc.text("Source Document:", cardTextX, row2Y); + setFont(10.5, "normal", C.CONTENT); + _doc.text(srcLines[0] ?? "", cardTextX + srcLabelW, row2Y); + for (let i = 1; i < srcLines.length; i++) { + _doc.text(srcLines[i], cardTextX, row2Y + i * rowLineH); + } + + _y = cardY + dynamicCardH + 6.4; // 24px gap metadata → content + + // ── Condition text (if present before subconditions) ────────────────────────── + if (condition.condition_text?.trim()) { + setFont(12, "normal", C.DARK); + const txtLines = _doc.splitTextToSize(condition.condition_text, CW) as string[]; + for (const line of txtLines) { + ensureSpace(lh(12, 1.5) + 1); + _doc.text(line, ML, _y); + _y += lh(12, 1.5); + } + _y += 6.4; // 24px gap before subconditions + } + + // ── Subconditions ───────────────────────────────────────────────────────────── + if (condition.subconditions && condition.subconditions.length > 0) { + renderSubconditions(condition.subconditions, ML, 0); + } + + _y += 12.7; // ~50px space before separator + + // ── Bottom separator ────────────────────────────────────────────────────────── + drawHRule(ML, _y, PW - MR, C.SEPARATOR, 0.5); + _y += 12.7; // 48px gap between condition blocks +} + +// ─── Image loader ────────────────────────────────────────────────────────────── + +async function loadImageAsDataUrl(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { reject(new Error("No canvas context")); return; } + ctx.drawImage(img, 0, 0); + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = () => reject(new Error(`Failed to load: ${url}`)); + img.src = url; + }); +} + +// ─── Main export function ────────────────────────────────────────────────────── + +export async function generateConsolidatedConditionsPDF( + conditions: ConditionModel[], + projectName: string, + eaoLogoUrl?: string +): Promise { + _doc = new jsPDF("portrait", "mm", "a4"); + _y = MT; + + let eaoLogoData: string | undefined; + + if (eaoLogoUrl) { try { eaoLogoData = await loadImageAsDataUrl(eaoLogoUrl); } catch { /* ignore */ } } + + // Header (logos, title, stats, disclaimer) + addHeader(projectName, conditions, eaoLogoData); + + // Conditions — continuous flow + for (const condition of conditions) { + renderCondition(condition); + } + + // Disclaimer on every page (last page drawn here; others drawn in newPage()) + drawDisclaimer(); + + const safeName = projectName.replace(/[^a-z0-9]/gi, "_").replace(/_+/g, "_"); + _doc.save(`Consolidated_Conditions_${safeName}.pdf`); +}